Some years ago, I wrote a tutorial on creating a Mahjong game in JavaScript. Back then, I used a single file to hold all of the images. A common way of cutting down HTTP requests. However, it made changing styles, sizes, and such very painful. Especially when the changes involved size.
Shrinking the tiles? Easy, just a quick CSS tweak and you’re done. But making them bigger without turning everything into a blurry mess? That’s a whole different story.
Now, everything’s about to change in the new Mahjong game we’re building! The new version will give the CSS more power. No more redoing the big images file to nudge a pixel. For most changes, we’ll tweak some CSS and call it a day. We are also bringing in SVGs, so the tiles will always look sharp. Goodbye, blurry graphics!
This guide is about the tile. We will go over:
- How it’s built
- How it hangs out with its tile buddies
- How we can make it look good
We’ll end with a little mock board. We won’t get into any game logic or layouts. That’s coming soon, I promise. For now, let’s focus on the humble tile.
Creating the basic tile
Let’s think ahead about how we’re going to represent a tile. Since it’s an interactive game piece, a `<button>` seems like the obvious choice. It’s user-friendly and improves accessibility. If you’re interested, check out the guide I posted on making accessible web games. With one line of HTML, we’ve got a lot of our tile requirements covered:
<button class="tile" type="button" aria-label="Empty Mahjong tile"></button>
Since this button has no visible text, the aria-label steps in to give it a proper name for screen readers. The CSS? Well, it needs a little more love. Let’s start with the basics:
:root {
--tile-width: 3.125rem;
--tile-height: 4.1875rem;
--tile-radius: 2px;
}
.tile {
position: relative;
display: block;
box-sizing: border-box;
inline-size: var(--tile-width);
block-size: var(--tile-height);
padding: 0;
border: 1px solid #ebe8dd;
border-radius: var(--tile-radius);
background: #fffef6;
cursor: pointer;
}
At the top, we’ve set up a few custom properties to keep our measurements clean. These are our CSS custom properties (also known as CSS variables), which allow us to name a value once and then reuse it everywhere. As various elements are going to be using the same tile dimensions, setting `–tile-width` and `–tile-height` will automatically impact all the relevant calculations. Less manual calculation = less opportunity for bugs and less loss of hair. Keep it DRY.
We’re using `inline-size` and `block-size` instead of plain old width and height. Why? It helps if we ever switch to vertical writing mode or get fancy with different layouts. Logical names make it easier to see how things flow, which is always a win.
Quick note: we’re using rems for the main sizes, so everything scales with the root font size (usually 16px). Borders and the tiny corner radius? They’re in pixels, on purpose. We want those details to stay sharp, not disappear when the tile changes size. Every unit has its job.
To keep things predictable, we’re using `box-sizing: border-box`. That way, the tile size already includes the border. Otherwise, a one-pixel border would sneak in and make the button 2px bigger in both directions. No thanks! We’re also taking care of the button padding, background, and border.
Now, our tile button looks more like a game tile and less like a standard button:
But hey, we’re only just getting started!
Adding gentle shading
A flat piece of ivory is fine and all. However, we can make it even better. Time for a little gradient magic.
There are a few types of gradients to consider:
- Linear gradient: a straight line (up, down, diagonal)
- Radial gradient: expands outward from the center (circular or elliptical)
- Conic gradient: creates angular rotation (360 degrees)
We could talk about repeating gradients and all sorts of fancy stuff, but for our tile, a simple CSS linear gradient does the trick. It gives us that sweet highlight-to-shadow look.
:root {
--tile-face-light: #fffef6;
--tile-face-dark: #eeeadd;
--tile-face-gradient: linear-gradient(
145deg,
var(--tile-face-light) 0%,
var(--tile-face-dark) 100%
);
}
.tile {
background-color: var(--tile-face-dark);
background-image: var(--tile-face-gradient);
}
At 145 degrees, the lighter color sits in the upper left, and the darker shade sits in the lower right. The percentages are just color stops: 0% and 100%. The browser handles the rest, making everything look sharp at any size. And since it’s all CSS, changing colors or angles is as easy as changing your mind.
Our tile now looks a bit better than before:
Creating visual depth
Mahjong boards usually have overlapping layers. To help players see which tile is on top, we are adding a bit of visible thickness. Plus, it just looks good.
There are a few ways to pull off that depth effect:
- box-shadow is wonderfully short, but it does not give us precise control.
- 3D transforms and perspective can produce real geometric depth. But can also be too complex for our current needs.
- A raster image would work, but we did that last time.
- We can use extra HTML elements to draw the sides. But that would clutter the markup for no good reason.
Instead, we’ll let the button’s `::before` and `::after` pseudo-elements each wear an SVG background. Pseudo-elements are like bonus decorative boxes. No extra HTML required. With SVGs, we get full control over the shapes.
Drawing the SVG
The source SVG uses its own coordinate system. We created two named groups: bottom-depth and right-depth. Each group contains:
- a pale inner plane
- a darker outer strip
- short paths that finish the exposed corners and shared divider.
Here’s what we’re aiming for:
And here is how we achieved that:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 444 592" preserveAspectRatio="none">
<!-- The bottom plane will become the button's ::before background. -->
<g id="bottom-depth" shape-rendering="geometricPrecision">
<path id="bottom-outer-layer"
d="M422.728 570.728 A18 18 0 0 1 410 576 H18 A18 18 0 0 1 5.272 570.728 L21.272 586.728 A18 18 0 0 0 34 592 H426 A18 18 0 0 0 438.728 586.728 Z"
fill="#adb0a5" stroke="#adb0a5" stroke-width="1" stroke-linejoin="round"></path>
<path id="bottom-inner-layer"
d="M422.728 570.728 A18 18 0 0 1 410 576 H18 A18 18 0 0 1 5.272 570.728 L17.272 582.728 A18 18 0 0 0 30 588 H422 A18 18 0 0 0 434.728 582.728 Z"
fill="#eeeadd" stroke="#ebe8dd" stroke-width="1" stroke-linejoin="round"></path>
<!-- Split the left cap into pale and dark sections. -->
<path d="M5.272 570.728 L17.272 582.728" fill="none" stroke="#ebe8dd" stroke-width="1"></path>
<path d="M17.272 582.728 L21.272 586.728" fill="none" stroke="#adb0a5" stroke-width="1"></path>
<!-- Finish the lower-right divider where both planes meet. -->
<path id="bottom-right-divider-inner" d="M422.728 570.728 L435.228 583.228" fill="none" stroke="#ebe8dd"
stroke-width="1" stroke-linecap="butt"></path>
<path id="bottom-right-divider-outer" d="M435.228 583.228 L438.728 586.728" fill="none" stroke="#adb0a5"
stroke-width="1" stroke-linecap="butt"></path>
</g>
<!-- The right plane will become the button's ::after background. -->
<g id="right-depth" shape-rendering="geometricPrecision">
<path id="right-outer-layer"
d="M422.728 5.272 A18 18 0 0 1 428 18 V558 A18 18 0 0 1 422.728 570.728 L438.728 586.728 A18 18 0 0 0 444 574 V34 A18 18 0 0 0 438.728 21.272 Z"
fill="#adb0a5" stroke="#adb0a5" stroke-width="1" stroke-linejoin="round"></path>
<path id="right-inner-layer"
d="M422.728 5.272 A18 18 0 0 1 428 18 V558 A18 18 0 0 1 422.728 570.728 L434.728 582.728 A18 18 0 0 0 440 570 V30 A18 18 0 0 0 434.728 17.272 Z"
fill="#eeeadd" stroke="#ebe8dd" stroke-width="1" stroke-linejoin="round"></path>
<!-- Split the top cap into pale and dark sections. -->
<path d="M422.728 5.272 L434.728 17.272" fill="none" stroke="#ebe8dd" stroke-width="1"></path>
<path d="M434.728 17.272 L438.728 21.272" fill="none" stroke="#adb0a5" stroke-width="1"></path>
<!-- Repeat the matching lower-right divider in this resource. -->
<path id="right-bottom-divider-inner" d="M422.728 570.728 L435.228 583.228" fill="none" stroke="#ebe8dd"
stroke-width="1" stroke-linecap="butt"></path>
<path id="right-bottom-divider-outer" d="M435.228 583.228 L438.728 586.728" fill="none" stroke="#adb0a5"
stroke-width="1" stroke-linecap="butt"></path>
</g>
</svg>
The `viewBox=”0 0 444 592″` sets up a drawing area that’s 444×592. And nope, those units aren’t CSS pixels. They’re just SVG’s own thing. The browser does the heavy lifting, mapping that coordinate system onto whatever CSS size we throw at it. I’ve created this at a large scale, since it was easier to be semi-accurate. Unlike working with raster images, the size the vector graphic was designed at doesn’t have much effect on the file size (if any).
`preserveAspectRatio=”none”` tells the browser to stretch the SVG to fit the exact width and height of our pseudo-element. That’s intentional, since we set the SVG coordinate system to match this tile’s shape.
`shape-rendering=”geometricPrecision”` is just us politely asking the browser to keep those edges and curves as crisp as possible when it turns our vectors into pixels.
The `d` attribute is basically the path’s treasure map. Reading this map (or writing it) can be a bit overwhelming, so we will keep it outside this tutorial. If you want to learn more about this topic, CSS-Tricks has an excellent illustrated guide to SVG path syntax.
Sure, we could build the edges from rectangles, polygons, or polylines. Polylines are great for straight lines, but they can’t handle these fancy curved corners. Paths, though? They’re the Swiss Army knife here. They handle straight lines, arcs, filled shapes, open dividers, and whatnot. Perfect for what we need.
Hand-coding SVG is a neat trick, but honestly, there are plenty of tools to edit vectors. So don’t let the SVG code scare you off.
Turning the SVG into CSS
We want these shapes to tag along with our CSS instead of making the browser fetch extra files. So, each group gets turned into an SVG data URL. That means we put the SVG right where a file URL would normally go. SVGs are full of special characters, though, so we have to encode them first. Otherwise, things get weird.
To create a fully encoded valid value, you can follow a simple process:
- Optional: Remove comments and newlines from the SVG
- Open the browser developer console
- Use the following code (don’t forget to use your SVG string instead of the placeholder)
const svg = `placeholder`; const dataUrl = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; console.log(dataUrl); - Then copy the output and drop it into your CSS. Easy win.
We placed the encoded SVGs in custom properties. That keeps the CSS easier to read and maintain. We have one handy spot to swap out shapes. If we prepared the SVG data URL with CSS variables, we could use a little JavaScript to control the colors as well. You can read more about this technique in How To: Recoloring SVG backgrounds with CSS and JS.
:root {
--tile-bottom-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 444 592' preserveAspectRatio='none'%3E%3Cg id='bottom-depth' shape-rendering='geometricPrecision'%3E%3Cpath id='bottom-outer-layer' d='M422.728 570.728 A18 18 0 0 1 410 576 H18 A18 18 0 0 1 5.272 570.728 L21.272 586.728 A18 18 0 0 0 34 592 H426 A18 18 0 0 0 438.728 586.728 Z' fill='%23adb0a5' stroke='%23adb0a5' stroke-width='1' stroke-linejoin='round'/%3E%3Cpath id='bottom-inner-layer' d='M422.728 570.728 A18 18 0 0 1 410 576 H18 A18 18 0 0 1 5.272 570.728 L17.272 582.728 A18 18 0 0 0 30 588 H422 A18 18 0 0 0 434.728 582.728 Z' fill='%23eeeadd' stroke='%23ebe8dd' stroke-width='1' stroke-linejoin='round'/%3E%3Cpath d='M5.272 570.728 L17.272 582.728' fill='none' stroke='%23ebe8dd' stroke-width='1'/%3E%3Cpath d='M17.272 582.728 L21.272 586.728' fill='none' stroke='%23adb0a5' stroke-width='1'/%3E%3Cpath id='bottom-right-divider-inner' d='M422.728 570.728 L435.228 583.228' fill='none' stroke='%23ebe8dd' stroke-width='1' stroke-linecap='butt'/%3E%3Cpath id='bottom-right-divider-outer' d='M435.228 583.228 L438.728 586.728' fill='none' stroke='%23adb0a5' stroke-width='1' stroke-linecap='butt'/%3E%3C/g%3E%3C/svg%3E");
--tile-right-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 444 592' preserveAspectRatio='none'%3E%3Cg id='right-depth' shape-rendering='geometricPrecision'%3E%3Cpath id='right-outer-layer' d='M422.728 5.272 A18 18 0 0 1 428 18 V558 A18 18 0 0 1 422.728 570.728 L438.728 586.728 A18 18 0 0 0 444 574 V34 A18 18 0 0 0 438.728 21.272 Z' fill='%23adb0a5' stroke='%23adb0a5' stroke-width='1' stroke-linejoin='round'/%3E%3Cpath id='right-inner-layer' d='M422.728 5.272 A18 18 0 0 1 428 18 V558 A18 18 0 0 1 422.728 570.728 L434.728 582.728 A18 18 0 0 0 440 570 V30 A18 18 0 0 0 434.728 17.272 Z' fill='%23eeeadd' stroke='%23ebe8dd' stroke-width='1' stroke-linejoin='round'/%3E%3Cpath d='M422.728 5.272 L434.728 17.272' fill='none' stroke='%23ebe8dd' stroke-width='1'/%3E%3Cpath d='M434.728 17.272 L438.728 21.272' fill='none' stroke='%23adb0a5' stroke-width='1'/%3E%3Cpath id='right-bottom-divider-inner' d='M422.728 570.728 L435.228 583.228' fill='none' stroke='%23ebe8dd' stroke-width='1' stroke-linecap='butt'/%3E%3Cpath id='right-bottom-divider-outer' d='M435.228 583.228 L438.728 586.728' fill='none' stroke='%23adb0a5' stroke-width='1' stroke-linecap='butt'/%3E%3C/g%3E%3C/svg%3E");
}
The SVGs are based on a 428×576-unit face, plus 16 units of extra depth for good measure. The `rem` values below shrink all that down to a tidy 50×67 pixel tile face at the usual root size.
:root {
--tile-depth-x: 0.1168rem;
--tile-depth-y: 0.1163rem;
}
/* Now the pseudo-elements can display the resources */
.tile::before,
.tile::after {
content: "";
position: absolute;
inset: -1px auto auto -1px;
z-index: -1;
inline-size: calc(var(--tile-width) + var(--tile-depth-x));
block-size: calc(var(--tile-height) + var(--tile-depth-y));
background-position: 0 0;
background-repeat: no-repeat;
background-size: 100% 100%;
pointer-events: none;
}
.tile::before {
background-image: var(--tile-bottom-shape);
}
.tile::after {
background-image: var(--tile-right-shape);
}
`content: “”` makes the pseudo-element available, and `position: absolute` takes it out of the normal layout flow. Since the tile is `position: relative`, all those absolute values are measured from the tile itself. No wandering off allowed.
A negative `z-index` tucks both shapes behind the button face, only showing the depth that sticks out past the bottom and right edges. We set the pseudo-elements’ size to match the tile face plus the extra depth. We use `calc()` to mix and match `rem` and `px` values.
`background-size: 100% 100%` stretches each SVG to fill its whole box. Meanwhile, setting the background position to the top left keeps both SVGs aligned to the same coordinates.
And finally, `pointer-events: none` makes sure our fancy decoration doesn’t accidentally steal clicks from the button itself.
Since a negative stacking level can sometimes sneak behind things it shouldn’t, we use `isolation: isolate` on the tile container (like the board). We’ll come back to this trick when we build the grid.
Add drawings without adding elements
Now every tile can reuse the same face and depth. However, the tile is empty. Not exactly thrilling. We want different tiles in our game, so we need to add some drawings. Sure, we could toss an `<img>` inside the button, but why clutter things up with extra elements? Instead, let’s stack a second background image right on top of the gradient. That way, we can swap out the `–tile-face-image` with a modifier class.
:root {
--tile-face-image: none;
--tile-face-gradient: linear-gradient(
145deg,
var(--tile-face-light) 0%,
var(--tile-face-dark) 100%
);
}
.tile {
background-image:
var(--tile-face-image),
var(--tile-face-gradient);
background-position: center;
background-repeat: no-repeat;
background-size: 72% 72%, 100% 100%;
}
.tile--star-1 {
--tile-face-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath d='M0-10 2.35-3.24 9.51-3.09 3.8 1.24 5.88 8.09 0 4-5.88 8.09-3.8 1.24-9.51-3.09-2.35-3.24Z' transform='translate(50 50) scale(2.3)' fill='%239e3548' stroke='%236f2031' stroke-width='1.5' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.tile--star-5 {
--tile-face-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3Cpath id='star' d='M0-10 2.35-3.24 9.51-3.09 3.8 1.24 5.88 8.09 0 4-5.88 8.09-3.8 1.24-9.51-3.09-2.35-3.24Z'/%3E%3C/defs%3E%3Cuse href='%23star' transform='translate(50 20) scale(1.15)' fill='none' stroke='%239e3548' stroke-width='3.5' stroke-linejoin='round'/%3E%3Cg fill='%23176b68' stroke='%230e4c4b' stroke-width='1.5' stroke-linejoin='round'%3E%3Cuse href='%23star' transform='translate(31 50) scale(1.15)'/%3E%3Cuse href='%23star' transform='translate(69 50) scale(1.15)'/%3E%3Cuse href='%23star' transform='translate(31 80) scale(1.15)'/%3E%3Cuse href='%23star' transform='translate(69 80) scale(1.15)'/%3E%3C/g%3E%3C/svg%3E");
}
CSS stacks backgrounds from front to back, so `–tile-face-image` sits right on top of `–tile-face-gradient`. The `background-size` values follow the same order. The drawing covers 72% of the face, and the gradient covers the whole thing. Both layers are centered and set to no-repeat, nice and tidy.
The five-star SVG defines the star path once inside `<defs>`, then uses `<use>` to stamp out copies. The first star has no fill, while the other four inherit the group’s teal fill and dark stroke. Reusing the path keeps the data URL short and ensures every star is a perfect match.
We can go a step further and prepare the SVG with CSS variables, then use JavaScript to apply the colors. You can see how to make that work in our Recoloring SVG backgrounds guide.
<button class="tile tile--star-1" type="button" aria-label="Mahjong tile with one filled star"></button> <button class="tile tile--star-5" type="button" aria-label="Mahjong tile with four filled stars and one outlined star"></button>
The class determines which artwork to show, and the accessible label tells screen readers what the button does (since the art is basically invisible to them).
Now our buttons really look like game tiles:
Add interaction states
CSS pseudo-classes let the tile react without us having to poke at the markup.
- `:hover` jumps in when your mouse is over the button.
- `:focus-visible` is like the keyboard-friendly cousin of `:focus` and shows a bold focus ring when the browser thinks it’s needed.
- `:active` only hangs around while you’re actually pressing the button. Don’t blink, or you’ll miss it.
:root {
--tile-selected: #d39320;
}
.tile:hover,
.tile:focus-visible,
.tile:active {
filter: saturate(3);
}
.tile:focus-visible {
outline: 0.125rem solid #1677ff;
outline-offset: -0.375rem;
}
.tile:active {
box-shadow: inset 0 0 0 0.125rem var(--tile-selected),
inset 0 0 0 0.1875rem #fff;
}
A shared saturation filter makes both the artwork and the gradient pop, no background swapping required. The focus outline gets pulled inside the tile, and those pressed rings? Just some inset shadows doing their thing. Neither effect messes with the border width or the button’s size, so your tile won’t get all pushy and start crowding its neighbors.
`:active` is just a quick visitor. In a real Mahjong game, you’d want to remember which tile the player picked even after they let go, update its accessibility state, and clear it when the matching rules say so. We’ll handle that in the JavaScript game tutorial. No pretending CSS can do magic tricks here.
Bring the tiles together
The last piece of this foundation is a board where all the tile faces are cozy and touching. Here’s a 3×3 HTML sample with 4 tiles with drawings and 5 empty. It’s like a little tile family reunion.
<div class="tile-board" role="group" aria-label="Mahjong tile board"> <button class="tile" type="button" aria-label="Empty Mahjong tile 1"></button> <button class="tile tile--star-5" type="button" aria-label="Mahjong tile 2 with five stars"></button> <button class="tile" type="button" aria-label="Empty Mahjong tile 3"></button> <button class="tile tile--star-1" type="button" aria-label="Mahjong tile 4 with one star"></button> <button class="tile" type="button" aria-label="Empty Mahjong tile 5"></button> <button class="tile tile--star-5" type="button" aria-label="Mahjong tile 6 with five stars"></button> <button class="tile" type="button" aria-label="Empty Mahjong tile 7"></button> <button class="tile tile--star-1" type="button" aria-label="Mahjong tile 8 with one star"></button> <button class="tile" type="button" aria-label="Empty Mahjong tile 9"></button> </div>
The CSS classes decide which drawing each button gets.
.tile-board {
display: grid;
grid-template-columns: repeat(
3,
calc(var(--tile-width) - 1px)
);
grid-template-rows: repeat(
3,
calc(var(--tile-height) - 1px)
);
inline-size: max-content;
isolation: isolate;
}
Grid is a perfect fit here because our board is obviously two-dimensional: columns, rows.
Each grid track is 1 pixel smaller than the tile. Since the button still uses the full `–tile-width` by `–tile-height`, it pokes one pixel into the next track. That way, neighboring one-pixel borders overlap, and you get a single clean dividing line instead of a chunky two-pixel seam.
`calc()` is our friend here, letting us subtract that 1px border from a rem-based custom property.
`inline-size: max-content` tells the board to only be as wide as its columns need, instead of stretching across the whole page like it owns the place. Like before, inline-size is just the logical version of width. So the board hugs its tiles, and it still works with different writing modes.
And finally, `isolation: isolate` gives the board its own stacking context. We also gave the pseudo-elements `z-index: -1` so they will sit behind their faces. Isolation keeps those negative layers safely inside the board. This way, nothing goes wandering off behind the page or starts messing with other stuff.
Here is what our 3×3 grid looks like:
Go ahead, hover over and click the tiles to see the different states.
Final words
We now have a single reusable button that gives us the face, shading, curved depth, artwork layer, and temporary interaction feedback — all in one neat package. Unlike the old combined image, we defined the shared parts only once. We can recolor the CSS-drawn parts with custom properties, resize the tiles without redrawing anything, and add a new piece just by assigning a different face-image class.
As mentioned earlier, since the SVGs are self-contained in the data URL, changing their colors takes a bit more work. It requires some JS and coordination with the SVG. We left this part out of the tutorial to keep it simple. You can read about it in our SVG background recoloring tutorial.
The next tutorial will put these graphics to work. We’ll build on the tiles and get to focus on other parts of the game. It’s going to be pretty exciting stuff, so stick around!