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};
}
Sure, nothing’s showing up in the preview yet, but the real magic starts now.
Now we move on to writing mixins. I’ll explain the approach in detail for the first mixin, and the rest will be described through comments.
The first mixin dynamically applies grid-column
and grid-row
placement rules to child elements based on values stored in a map. Each entry in the map corresponds to an element index (1 through 8) and contains a list of four values: [start-col, end-col, start-row, end-row]
.
@mixin tanagram-grid-positioning($nth-child-grid) {
// Loop through numbers 1 to 8, corresponding to the tanam pieces
@for $i from 1 through 8 {
// Check if the map contains a key for the current piece (1-8)
@if map.has-key($nth-child-grid, $i) {
// Get the grid values for this piece: [start-column, end-column, start-row, end-row]
$values: map.get($nth-child-grid, $i);
// Target the nth child (piece) and set its grid positions
&:nth-child(#{$i}) {
// Set grid-column: start and end values based on the first two items in the list
grid-column: #{list.nth($values, 1)} / #{list.nth($values, 2)};
// Set grid-row: start and end values based on the last two items in the list
grid-row: #{list.nth($values, 3)} / #{list.nth($values, 4)};
}
}
}
}
We can expect the following CSS to be generated:
.tanagram-box:nth-child(1) {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
.tanagram-box:nth-child(2) {
grid-column: 3 / 4;
grid-row: 1 / 2;
}
In this mixin, my goal was actually to create all the shapes (tans). I am using clip-path
. There were ideas to use fancy SVG images, but this test project is more about testing the logic rather than focusing on beautiful design. For this reason, the simplest solution was to cut the elements according to dimensions while they are still in the square (the initial position of all the tans).
So, in this case, through a static calculation, the $tansShapes
map was updated with the clip-path
property:
clip-path: (0 0, 50 50, 0 100);
This contains the clip points for all the tans. In essence, this mixin shapes and colors each tan accordingly.
@mixin set-tan-clip-path($tanName, $values) {
// Initialize an empty list to hold the final clip-path points
$clip-path-points: ();
// Extract the 'clip-path' data from the map, which contains coordinate pairs
$clip-path-key: map.get($values, clip-path);
// Get the number of coordinate pairs to loop through
$count: list.length($clip-path-key);
// Loop through each coordinate point
@for $i from 1 through $count {
// Convert each pair of numbers into a formatted coordinate string with units
$current-point: get-coordinates($clip-path-key, $i, " ", "%");
// Add the formatted coordinate to the list, separating each point with a comma
$clip-path-points: list.append($clip-path-points, #{$current-point}, comma);
}
// Style for the preview element (lab version), using the configured background color
#tan#{$tanName}lab {
background: map.get($values, color);
clip-path: polygon(#{$clip-path-points}); // Apply the full list of clip-path points
}
// Apply the same clip-path to the actual tan element
.#{$tanName} {
clip-path: polygon(#{$clip-path-points});
}
}
and output in CSS should be:
.blueTriangle {
clip-path: polygon(0% 0%, 50% 50%, 0% 100%);
}
/* other tans */
Start logic
Alright, now I’d like to clarify what should happen first when the game loads.
First, with a click on the Start button, all the tans “go to their positions.” In reality, we assign them a transform: translate()
with specific coordinates and a rotation.
.start:checked ~ .shadow #tanblueTrianglelab {
transform-origin: 4.17vmin 12.5vmin;
transform: translate(-6vmin,-37vmin) rotate(360deg);
cursor: pointer;
}
So, we still maintain this pattern. We use transform and simply change the positions or angles (in the maps) of both the tans and their shadows on the task board.
When any tan is clicked, the rotation button appears. By clicking on it, the tan should rotate around its center, and this continues with each subsequent click. There are actually eight radio buttons, and with each click, one disappears and the next one appears. When we reach the last one, clicking it makes it disappear and the first one reappears. This way, we get the impression of clicking the same button (they are, of course, styled the same) and being able to click (rotate the tan) infinitely. This is exactly what the following mixin enables.
@mixin set-tan-rotation-states($tanName, $values, $angles, $color) {
// This mixin dynamically applies rotation UI styles based on a tan's configuration.
// It controls the positioning and appearance of rotation buttons and visual feedback when a rotation state is active.
@each $angle in $angles{
& ~ #rot#{$angle}{ transform: translate(get-coordinates($values,rot-btn-position,',',vmin )); background: $color;}
& ~ #rotation-#{$angle}:checked{
@each $key in map.keys($tansShapes){
& ~ #tan#{$key}labRes{ visibility: visible; background:rgba(0,0,0,0.4); }
& ~ #tan#{$key}lab{ opacity:.3; }
& ~ #rotReset{ visibility: visible; }
}
}
}
}
And the generated CSS should be:
#blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelab {
transform: translate(-6vmin,-37vmin) rotate(45deg);
}
#blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelabRes {
visibility: hidden;
}
OK, the following mixins use the set-clip-path
and set-rotation
mixins. They contain all the information about the tans and their behavior in relation to which tan is clicked and which rotation is selected, as well as their positions (as defined in the second mixin).
@mixin generate-tan-shapes-and-interactions($tansShapes) {
// Applies styling logic and UI interactions for each individual tan shape from the $tansShapes map.
@each $tanName, $values in $tansShapes{
$color: color.scale(map.get($values, color), $lightness: 10%);
$angles: (45, 90, 135, 180, 225, 270, 315, 360);
@include set-tan-clip-path($tanName, $values);
##{$tanName}-tan:checked{
& ~ #tan#{$tanName}Res{ visibility:hidden; }
& ~ #tan#{$tanName}lab{opacity: 1 !important;background: #{$color};cursor:auto;}
@each $key in map.keys($tansShapes){
& ~ #tan#{$tanName}Res:checked ~ #tan#{$key}labRes{visibility: visible;}
}
& ~ #rot45{display: flex;visibility: visible;}
& ~ #rotReset{ transform: translate(get-coordinates($values, exit-mode-btn-position,',', vmin)); }
@include set-tan-rotation-states($tanName, $values, $angles, $color);
}
}
}
@mixin set-initial-tan-position($tansShapes) {
// This mixin sets the initial position and transformation for both the interactive (`lab`) and shadow (`labRes`) versions
// of each tan shape, based on coordinates provided in the $tansShapes map.
@each $tanName, $values in $tansShapes{
& ~ .shadow #tan#{$tanName}lab{
transform-origin: get-coordinates($values, transform-origin,' ' ,vmin);
transform: translate( get-coordinates($values,tan-position,',', vmin)) rotate(360deg) ;
cursor: pointer;
}
& ~ .shadow #tan#{$tanName}labRes{
visibility:hidden;
transform: translate(get-coordinates($values,diable-lab-position,',',vmin));
}
}
}
As mentioned earlier, when a tan is clicked, one of the things that becomes visible is its shadow — a silhouette that appears on the task board.
These shadow positions (coordinates) are currently defined statically. Each shadow has a specific place on the map, and a mixin reads this data and applies it to the shadow using transform: translate()
.
When the clicked tan is rotated, the number of visible shadows on the task board can change, as well as their angles, which is expected.
Of course, special care was taken with naming conventions. Each shadow element gets a unique ID, made from the name (inherited from its parent tan) and a number that represents its sequence position for the given angle.
Pretty cool, right? That way, we avoid complicated naming patterns entirely!
@mixin render-possible-tan-positions( $name, $angle, $possiblePositions, $visibility, $color, $id, $transformOrigin ) {
// This mixin generates styles for possible positions of a tan shape based on its name, rotation angle, and configuration map.
// It handles both squares and polygons, normalizing their rotation angles accordingly and applying transform styles if positions exist.}
@if $name == 'square' {
$angle: normalize-angle($angle); // Normalizujemo ugao ako je u pitanju square
} @else if $name == 'polygon'{
$angle: normalize-polygon-angle($angle);
}
@if map.has-key($possiblePositions, $angle) {
$values: map.get($possiblePositions, $angle);
@if $values != none {
$count: list.length($values);
@for $i from 1 through $count {
$position: get-coordinates($values, $i, ',', vmin);
& ~ #tan#{$name}lab-#{$i}-#{$angle} {
@if $visibility == visible {
visibility: visible;
background-color: $color;
opacity: .2;
z-index: 2;
transform-origin: #{$transformOrigin};
transform: translate(#{$position}) rotate(#{$angle}deg);
} @else if $visibility == hidden { visibility: hidden; }
&:hover{ opacity: 0.5; cursor: pointer; }
}
}
}
}
}
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#{$tanName}lab{transform:translate( get-coordinates($values,tan-position,',', vmin)) rotate(#{$angle}deg) ;}
& ~ #tan#{$tanName}labRes{ 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.