Symbology Tech: CSS Variables

Published Sunday, May 8, 2022 by Bryan

This post is the continuation of a series of technical writeups of things I learned creating the game Symbology. The introductory post discussed the basics of how SVG, CSS, and Javascript work together to run the game.

Today I'm going to talk about the most surprising and amazing technological development I found while creating Symbology: CSS custom properties, or "variables".

Finally, you can define names for values like re-used colors, like so:

svg {
    --unlit-tile-fill: rgba(153, 153, 153, 0.5);
    --lit-tile-fill: rgba(238, 238, 238, 0.75);
}

...and then use them in other places, like so:

.button rect,
.button circle,
.panel.dialogblock .button:active:not(.disabled) rect,
.panel.dialogblock .button:active:not(.disabled) circle {
    fill: var(--unlit-tile-fill);
    transform: none; /* override active highlight when panel isdisabled */
    transition: fill 0.15s linear, transform 0.15s linear;
}

.button:active:not(.disabled) rect,
.button:active:not(.disabled) circle {
    fill: var(--lit-tile-fill);
    transform: scale(1.1);
}

g.playTile rect.background {
    fill: var(--lit-tile-fill);
    transition: transform 0.15s linear;
}

g.playTile.unlit rect.background {
    fill: var(--unlit-tile-fill);
}
The same colors are used for both button backgrounds and tile backgrounds, via a custom CSS property

This is something I've used many templating languages to do before now, and it is so useful to have it supported directly in standard CSS.

And it's not the surprising and amazing part.

The surprising and amazing part is how scope works on these variables. In one sense, it is exactly like every other inherited CSS property. They cascade and they they accumulate. Symbology uses this in a number of ways. The simplest might be in the "hint arrows" that help you learn the rules of the game.

Hint arrows are one arrow definition, rotated to point the right way
g.hint .arrow {
    fill: #ffffff;
    mask: url(#hintArrowMask);
    stroke: none;
    transform: rotate(var(--hint-arrow-rotation)) scale(var(--playTile-scale));
}

g.hint.left .arrow { --hint-arrow-rotation: 0deg; }
g.hint.right .arrow { --hint-arrow-rotation: 180deg; }
g.hint.above .arrow { --hint-arrow-rotation: 90deg; }
g.hint.below .arrow { --hint-arrow-rotation: -90deg; }

The g.hint .arrow style specifies a generic transform that depends on variables that are defined elsewhere. When adding the hints to the SVG, an additional class is added - left, right, above, or below - that sets the variable used in the rotate transform, to make the arrow point to the correct neighboring tile.

But there's a surprise second amazing part as well. You can do basic math with these variables! For example, Symbology specifies the animation of the twirling/bouncing pieces on the launch screen like this:

<g id="launchShapes">
  <g id="launchShapePos0"><!-- g.piece goes here --></g>
  <g id="launchShapePos1"><!-- g.piece goes here --></g>
  <g id="launchShapePos2"><!-- g.piece goes here --></g>
  <g id="launchShapePos3"><!-- g.piece goes here --></g>
  <g id="launchShapePos4"><!-- g.piece goes here --></g>
</g>
#launchShapes g.piece {
    animation-duration: 5s;
    animation-delay: calc(5s + var(--launch-pos-anim-delay));
    animation-iteration-count: 5;
}

#launchShapePos0 {
    transform: translateX(-120px) scale(0.5);
    --launch-pos-anim-delay: 0s;
}
#launchShapePos1 {
    transform: translateX(-60px) scale(0.5);
    --launch-pos-anim-delay: 0.1s;
}
#launchShapePos2 {
    transform: scale(0.5);
    --launch-pos-anim-delay: 0.2s;
}
#launchShapePos3 {
    transform: translateX(60px) scale(0.5);
    --launch-pos-anim-delay: 0.3s;
}
#launchShapePos4 {
    transform: translateX(120px) scale(0.5);
    --launch-pos-anim-delay: 0.4s;
}
Each position starts its animation 100ms later than the position to its left.

The base delay is five seconds, and then each piece from left to right adds an additional 100 milliseconds to that, via a --launch-pos-anim-delay variable. Sure, it would have been only a tiny irritation to specify the animation-delay as 5.0s, 5.1s, 5.2s, etc. in each position. But the way this method explicitly specifies the timing as base-plus-delay feels really feels like a clarity win.

Since these are just properties in regular CSS rules, they can be applied according to any matching pattern, such as the data attributes I discussed last time. Symbology is played on a grid. When I heard about CSS grids, I was excited. But from what I've tried, they don't work with SVG. This likely has to do with the fact that there is no sized "container" element in SVG, so there is nothing to fit the grid into. No matter, we can implement our own grid! With custom properties! And data attributes!

<g id="playGrid">
  <g class="playTile unlit">
    <rect class="background" rx="5" />
    <!-- piece goes here -->
  </g>
  <!-- duplicate playTile COLUMNS x ROWS times -->
</g>

Above is the bit of SVG representing the game board, as it arrives from the server (with a little editing to make things clearer in this example). When we set up the game, we'll duplicate the g.playTile element and its child for each tile on the board.

#playGrid {
    --playGrid-width: 430;
    --playGrid-height: 580;
    --playGrid-spacing: 12;

    --playTile-width: calc((var(--playGrid-width)
                            - (var(--playGrid-spacing)
                               * (var(--playGrid-columns) - 1)))
                           / var(--playGrid-columns));
    --playTile-height: calc((var(--playGrid-height)
                             - (var(--playGrid-spacing)
                                * (var(--playGrid-rows) - 1)))
                            / var(--playGrid-rows));
    --playTile-size: min(var(--playTile-height),
                         var(--playTile-width));
    --playTile-spacing: calc((var(--playTile-size) + var(--playGrid-spacing))
                             * 1px);
}

#playGrid rect.background {
    x: calc(1px * var(--playTile-size) / -2);
    y: calc(1px * var(--playTile-size) / -2);
    width: calc(1px * var(--playTile-size));
    height: calc(1px * var(--playTile-size));
}

#playGrid g.playTile {
    --playTile-x: calc((var(--playTile-column)
                        - (var(--playGrid-columns) - 1) / 2)
                       * var(--playTile-spacing));
    --playTile-y: calc((var(--playTile-row)
                        - (var(--playGrid-rows) - 1) / 2)
                       * var(--playTile-spacing));

    transform: translate(var(--playTile-x), var(--playTile-y));
}

This is the first bit of CSS we'll apply to it. It starts with some constants that will bound the game board. I'm trying to keep everything onscreen inside of a 450x800 "pixel" space (I'll discuss units in a minute). Subtracting the space for the header (where the next piece, trash, score, and such goes) as well as a little bit of padding, leaves me with 430x580 pixels for the game board. The third constant specifies that there should be 12 pixels between a tile and its neighbor.

After the constants, the size of a tile is computed. What I want is square tiles that fill the allotted space as much as possible. So first I compute the maximum size a tile could have in each dimension, without considering the other dimension, to get --playTile-width and --playTile-height. Then I find the smaller of the two, and store that in --playTile-size. Finally, --playTile-spacing is the distance between the centers of any two neighboring tiles. Finally, all of that is applied to the size of the tile background, and to the position of the tile.

Alright! Now we have rules that will automatically set the size and position of everything in our game's grid. Only one thing left - telling this code what the size of the grid is, and which row and column each tile is in. The Javascript for that looks like this:

// data attributes on #playGrid for its size
playGrid.dataset.width = levelDef.columns;
playGrid.dataset.height = levelDef.rows;
...

// classes for tile row/column
for (let r = 0; r < levelDef.rows; r++) {
    for (let c = 0; c < levelDef.columns; c++) {
        let tile = playTile.cloneNode(true);
        tile.classList.add("row"+r);
        tile.classList.add("column"+c);
...

Yes, I used data attributes in one case and classes in the other. I'll convert the classes to data attributes in the future, but I think it's interesting that there's so little difference in the CSS code that uses them:

#playGrid[data-width='5'] { --playGrid-columns: 5; }
#playGrid[data-width='6'] { --playGrid-columns: 6; }
#playGrid[data-width='7'] { --playGrid-columns: 7; }

#playGrid[data-height='5'] { --playGrid-rows: 5; }
#playGrid[data-height='6'] { --playGrid-rows: 6; }
#playGrid[data-height='7'] { --playGrid-rows: 7; }
#playGrid[data-height='8'] { --playGrid-rows: 8; }

#playGrid g.column0 { --playTile-column: 0; }
#playGrid g.column1 { --playTile-column: 1; }
#playGrid g.column2 { --playTile-column: 2; }
#playGrid g.column3 { --playTile-column: 3; }
#playGrid g.column4 { --playTile-column: 4; }
#playGrid g.column5 { --playTile-column: 5; }
#playGrid g.column6 { --playTile-column: 6; }

#playGrid g.row0 { --playTile-row: 0; }
#playGrid g.row1 { --playTile-row: 1; }
#playGrid g.row2 { --playTile-row: 2; }
#playGrid g.row3 { --playTile-row: 3; }
#playGrid g.row4 { --playTile-row: 4; }
#playGrid g.row5 { --playTile-row: 5; }
#playGrid g.row6 { --playTile-row: 6; }
#playGrid g.row7 { --playTile-row: 7; }

Yes, we have arrived at the ugly and inflexible part. CSS in major browsers can't yet use data attribute values in custom property values directly. And that's why the choice of data attributes or classes doesn't matter here - we have to do the conversion manually in either case. Some time in the future we'll get to replace var(--playGrid-columns) with something like attr(data-width integer), and then it will make sense to also convert from .column0 to data-column="0". Then I can remove all 22 of these rules, while also gaining the ability to make grids even larger or smaller!

Four of the twelve possible board sizes that this grid definition can lay out.

Before closing, I said I'd say something about units. I actually have two things to say. The first is that there is a tiny disconnect between CSS and SVG. SVG's measurement units are unlabeled. Everything is unitless points on a plane, that are only mapped to pixels by the viewBox attribute. But, CSS coordinates and sizes must have units, and so when setting properties via CSS, SVG measurements are labeled "pixels" via px regardless.

The second thing I have to say about units is, if you have ever worked through an object-oriented-programming tutorial's example of modeling measurement units via the type system, I think you'll giggle when doing CSS calculations. The reason for the multiplication by 1px in the CSS in this post is that, in addition to the size and position, I also need a scaling factor:

#playGrid {
    /* ... code from above ... */

    --playTile-scale: calc(var(--playTile-size) / 100);
}

My game piece definitions are based on a 100x100 unit grid. I need to scale them down to fit the tiles. Scaling factors in CSS must be unitless, but the tile size (width and height in the earlier definition) must be in px units. It's impossible to remove units from a CSS value once applied (division requires the divisor to be unitless, so / 100px is invalid), so instead I do all the math without units, then apply pixels to the appropriate values via * 1px when necessary.

But there you have it, grids in CSS+SVG. Custom properties played a major role in allowing me to shift to the completely declarative presentation layer I described in my previous post. I've written all of this math in Swift, Javascript, and other procedural and funtional languages before, but I think it's awesome to write it purely as a bunch of formulas, and let the browser figure out where to apply them.

Categories: Development