For years, I believed that drag-and-drop games — especially those involving rotation, spatial logic, and puzzle solving — were the exclusive domain of JavaScript. Until one day, I asked AI:
“Is it possible to build a fully interactive Tangram puzzle game using only CSS?”
The answer: “No — not really. You’ll need JavaScript.” That was all the motivation I needed to prove otherwise.
But first, let’s ask the obvious question: Why would anyone do this?
Well…
- To know how far CSS can be pushed in creating interactive UIs.
- To get better at my CSS skills.
- And it’s fun!
Fair enough?
Now, here’s the unsurprising truth: CSS isn’t exactly made for this. It’s not a logic language, and let’s be honest, it’s not particularly dynamic either. (Sure, we have CSS variables and some handy built-in functions now, hooray!)
In JavaScript, we naturally think in terms of functions, loops, conditions, objects, comparisons. We write logic, abstract things into methods, and eventually ship a bundle that the browser understands. And once it’s shipped? We rarely look at that final JavaScript bundle — we just focus on keeping it lean.
Now ask yourself: isn’t that exactly what Sass does for CSS?
Why should we hand-write endless lines of repetitive CSS when we can use mixins and functions to generate it — cleanly, efficiently, and without caring how many lines it takes, as long as the output is optimized?
So, we put it to the test and it turns out Sass can replace JavaScript, at least when it comes to low-level logic and puzzle behavior. With nothing but maps, mixins, functions, and a whole lot of math, we managed to bring our Tangram puzzle to life, no JavaScript required.
Let the (CSS-only) games begin! 🎉
The game
The game consists of seven pieces: the classic Tangram set. Naturally, these pieces can be arranged into a perfect square (and many other shapes, too). But we need a bit more than just static pieces.
So here’s what I am building:
- A puzzle goal, which is the target shape the player has to recreate.
- A start button that shuffles all the pieces into a staging area.
- Each piece is clickable and interactive.
- The puzzle should let the user know when they get a piece wrong and also celebrate when they finish the puzzle.
The HTML structure
I started by setting up the HTML structure, which is no small task, considering the number of elements involved.
- Each shape was given seven radio buttons. I chose radios over checkboxes to take advantage of their built-in exclusivity. Only one can be selected within the same group. This made it much easier to track which shape and state were currently active.
- The start button? Also a radio input. A checkbox could’ve worked too, but for the sake of consistency, I stuck with radios across the board.
- The puzzle map itself is just a plain old
<div>
, simple and effective. - For rotation, we added eight radio buttons, each representing a 45-degree increment: 45°, 90°, 135°, all the way to 360°. These simulate rotation controls entirely in CSS.
- Every potential shadow position got its own radio button too. (Yes, it’s a lot, I know.)
- And to wrap it all up, I included a classic reset button inside a
<form>
using<button type="reset">
, so players can easily start over at any point.
Given the sheer number of elements required, I used Pug to generate the HTML more efficiently. It was purely a convenience choice. It doesn’t affect the logic or behavior of the puzzle in any way.
Below is a sample of the compiled HTML. It might look overwhelming at first glance (and this is just a portion of it!), but it illustrates the structural complexity involved. This section is collapsed to not nuke your screen, but it can be expanded if you’d like to explore it.
Open HTML Code
<div class="wrapper">
<div class="tanagram-box"></div>
<div class="tanagram-box"></div>
<form class="container">
<input class="hide_input start" type="checkbox" id="start" autofocus />
<button class="start-button" type="reset" id="restart">Restart</button>
<label class="start-button" for="start">Start </label>
<div class="shadow">
<input class="hide_input" type="radio" id="blueTriangle-tan" name="tan-active" />
<input class="hide_input" type="radio" id="yellowTriangle-tan" name="tan-active" />
<!-- Inputs for others tans -->
<input class="hide_input" type="radio" id="rotation-reset" name="tan-active" />
<input class="hide_input" type="radio" id="rotation-45" name="tan-rotation" />
<input class="hide_input" type="radio" id="rotation-90" name="tan-rotation" />
<!--radios for 90, 225, 315, 360 -->
<input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-135" name="tan-rotation" />
<input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-225" name="tan-rotation" />
<!-- radio for every possible shape shadows-->
<label class="rotation rot" for="rotation-45" id="rot45">⟲</label>
<label class="rotation rot" for="rotation-90" id="rot90">⟲</label>
<!--radios for 90, 225, 315, 360 -->
<label class="rotation" for="rotation-reset" id="rotReset">✘</label>
<label class="blueTriangle tans" for="blueTriangle-tan" id="tanblueTrianglelab"></label>
<div class="tans tan_blocked" id="tanblueTrianglelabRes"></div>
<!-- labels for every tan and disabled div -->
<label class="blueTriangle tans" for="blueTriangle-tan-1-90" id="tanblueTrianglelab-1-90"></label>
<label class="blueTriangle tans" for="blueTriangle-tan-1-225" id="tanblueTrianglelab-1-225"></label>
<!-- labels radio for every possible shape shadows-->
<div class="shape"></div>
</div>
</form>
<div class="tanagram-box"></div>
<div class="tanagram-box"></div>
<div class="tanagram-box"></div>
<div class="tanagram-box"></div>
<div class="tanagram-box"></div>
</div>
Creating maps for shape data
Now that HTML skeleton is ready, it’s time to inject it with some real power. That’s where our Sass maps come in, and here’s where the puzzle logic starts to shine.
Note: Maps in Sass hold pairs of keys and values, and make it easy to look up a value by its corresponding key. Like objects in JavaScript, dictionaries in Python and, well, maps in C++.
I’m mapping out all the core data needed to control each tangram piece (tan): its color, shape, position, and even interaction logic. These maps contain:
- the
background-color
for each tan, - the
clip-path
coordinates that define their shapes, - the initial position for each tan,
- the position of the blocking
div
(which disables interaction when a tan is selected), - the shadow positions (coordinates for the tan’s silhouette displayed on the task board),
- the grid information, and
- the winning combinations — the exact target coordinates for each tan, marking the correct solution.
$colors: ( blue-color: #53a0e0, yellow-color: #f7db4f, /* Colors for each tan */ );
$nth-child-grid: ( 1: (2, 3, 1, 2, ), 2: ( 3, 4, 1, 2, ), 4: ( 1, 2, 2, 3, ), /* More entries to be added */);
$bluePosiblePositions: ( 45: none, 90: ( (6.7, 11.2), ), 135: none, 180: none, /* Positions defined up to 360 degrees */);
/* Other tans */
/* Data defined for each tan */
$tansShapes: (
blueTriangle: (
color: map.get($colors, blue-color),
clip-path: ( 0 0, 50 50, 0 100, ),
rot-btn-position: ( -20, -25, ),
exit-mode-btn-position: ( -20, -33, ),
tan-position: ( -6, -37, ),
diable-lab-position: ( -12, -38, ),
poss-positions: $bluePosiblePositions,
correct-position: ((4.7, 13.5), (18.8, 13.3), ),
transform-origin: ( 4.17, 12.5,),
),
);
/* Remaining 7 combinations */
$winningCombinations: (
combo1: (
(blueTriangle, 1, 360),
(yellowTriangle, 1, 225),
(pinkTriangle, 1, 180),
(redTriangle, 4, 360),
(purpleTriangle, 2, 225),
(square, 1, 90),
(polygon, 4, 90),
),
);
You can see this in action on CodePen, where these maps drive the actual look and behavior of each puzzle piece. At this point, there’s no visible change in the preview. We’ve simply prepared and stored the data for later use.
Using mixins to read from maps
The main idea is to create reusable mixins that will read data from the maps and apply it to the corresponding CSS rules when needed.
But before that, we’ve elevated things to a higher level by making one key decision: We never hard-coded units directly inside the maps. Instead, we built a reusable utility function that dynamically adds the desired unit (e.g., vmin
, px
, etc.) to any numeric value when it’s being used. This way, when can use our maps however we please.
@function get-coordinates($data, $key, $separator, $unit)
$coordinates: null;
// Check if the first argument is a map
@if meta.type-of($data) == "map"
// If the map contains the specified key
@if map.has-key($data, $key)
// Get the value associated with the key (expected to be a list of coordinates)
$coordinates: map.get($data, $key);
// If the first argument is a list
@else if meta.type-of($data) == "list"
// Ensure the key is a valid index (1-based) within the list
@if meta.type-of($key) == "number" and $key > 0 and $key <= list.length($data)
// Retrieve the item at the specified index
$coordinates: list.nth($data, $key);
// If neither map nor list, throw an error
@else
@error "Invalid input: First argument must be a map or a list.";
// If no valid coordinates were found, return null
@if $coordinates == null
@return null;
// Extract x and y values from the list
$x: list.nth($coordinates, 1);
$y: list.nth($coordinates, -1); // -1 gets the last item (y)
// Return the combined x and y values with units and separator
@return #$x#$unit#$separator#$y#$unit;
The generated CSS:
#blueTriangle-tan:checked ~ #tanblueTrianglelab-1-360
visibility: visible;
background-color: #53a0e0;
opacity: 0.2;
z-index: 2;
transform-origin: 4.17vmin 12.5vmin;
transform: translate(4.7vmin,13.5vmin) rotate(360deg);
This next mixin is tied to the previous one and manages when and how the tan shadows appear while their parent tan is being rotated using the button. It listens for the current rotation angle and checks whether there are any shadow positions defined for that specific angle. If there are, it displays them; if not — no shadows!
@mixin render-possible-positions-by-rotation
// This mixin applies rotation to each tan shape. It loops through each tan, calculates its possible positions for each angle, and handles visibility and transformation.
// It ensures that rotation is applied correctly, including handling the transitions between various tan positions and visibility states.
@each $tanName, $values in $tansShapes
$possiblePositions: map.get($values, poss-positions);
$possibleTansColor: map.get($values, color);
$validPosition: get-coordinates($values, correct-position,',' ,vmin);
$transformOrigin: get-coordinates($values,transform-origin,' ' ,vmin);
$rotResPosition: get-coordinates($values,exit-mode-btn-position ,',' ,vmin );
$angle: 0;
@for $i from 1 through 8
$angle: $i * 45;
$nextAngle: if($angle + 45 > 360, 45, $angle + 45);
@include render-position-feedback-on-task($tanName,$angle, $possiblePositions,$possibleTansColor, #$tanName-tan, $validPosition,$transformOrigin, $rotResPosition);
##$tanName-tan
@include render-possible-tan-positions($tanName,$angle, $possiblePositions,hidden, $possibleTansColor, #$tanName-tan,$transformOrigin)
##$tanName-tan:checked
@include render-possible-tan-positions($tanName,360, $possiblePositions,visible, $possibleTansColor, #$tanName-tan,$transformOrigin);
& ~ #rotation-#$angle:checked
@include render-possible-tan-positions($tanName,360, $possiblePositions,hidden, $possibleTansColor, #$tanName-tan,$transformOrigin);
& ~ #tan#$tanNamelabtransform:translate( get-coordinates($values,tan-position,',', vmin)) rotate(#$angledeg) ;
& ~ #tan#$tanNamelabRes visibility: hidden;
& ~ #rot#$angle visibility: hidden;
& ~ #rot#$nextAngle visibility: visible
@include render-possible-tan-positions($tanName,$angle, $possiblePositions,visible, $possibleTansColor, #$tanName-tan,$transformOrigin);
When a tan’s shadow is clicked, the corresponding tan should move to that shadow’s position. The next mixin then checks whether this new position is the correct one for solving the puzzle. If it is correct, the tan gets a brief blinking effect and becomes unclickable, signaling it’s been placed correctly. If it’s not correct, the tan simply stays at the shadow’s location. There’s no effect and it remains draggable/clickable.
Of course, there’s a list of all the correct positions for each tan. Since some tans share the same size — and some can even combine to form larger, existing shapes — we have multiple valid combinations. For this Camel task, all of them were taken into account. A dedicated map with these combinations was created, along with a mixin that reads and applies them.
At the end of the game, when all tans are placed in their correct positions, we trigger a “merging” effect — and the silhouette of the camel turns yellow. At that point, the only remaining action is to click the Restart button.
Well, that was long, but that’s what you get when you pick the fun (albeit hard and lengthy) path. All as an ode to CSS-only magic!
Breaking Boundaries: Building a Tangram Puzzle With (S)CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.