Modernisme isn’t a museum thing in Barcelona. It’s underfoot, on housing blocks, in shopfronts, on posters from a hundred years ago that still turn up in every design history class, and it’s a big part of why I ended up studying graphic design in the first place.

Home still has its original hydraulic tiles, and almost every room got a different pattern.

A patch of each, all six motifs
Fan
Lens
Wedge
Star
Bloom
Weave

Hydraulic tile is pressed cement flooring, no kiln, no glaze. It took off in Catalonia from the 1860s and hit its artistic peak a few decades later with Modernisme, when the same movement giving Barcelona its wrought iron and stained glass gave ordinary hallways their own stone carpets. Ours have worn paler at the doorways than the corners, cement gone matte and soft underfoot, cool even now in August.

Four tiles, rotated by a fixed rule: that’s the whole trick behind the patches above. Nobody draws anything new. Position just decides which way each tile turns. I rebuilt the whole thing in CSS Grid, a real floor this time, forty-five tiles deep. Pick a motif, pick a palette, and it lays itself out:

Design your own tile floor
Motif
Palette
.floor {
  display: grid;
  grid-template-columns: repeat(9, 1fr);
  gap: 3px;
}

How it actually works

Fan, Lens and Wedge are the lazy half of this set, one shape dressed three different ways.

Fan is a radial-gradient() pinned to one corner and sized past the edge, so what renders is a bulging quarter-circle.

Fan
.cell--fan { background: radial-gradient(circle at 0% 0%, var(--tile-color) 0 62%, transparent 63%); }

Lens rounds two opposite corners away with border-radius: 0 70% 0 70%, squeezing the square into a soft, pointed oval.

Lens
.cell--lens { background: var(--tile-color); border-radius: 0 70% 0 70%; }

Wedge takes the same trick further and shrinks it: three corners rounded, one left sharp, sized down so it floats inside the cell instead of touching the edges, a teardrop pinned to its corner by the point.

Wedge
.cell--wedge::after {
  content: '';
  position: absolute;
  inset: 0 34% 34% 0;
  background: var(--tile-color);
  border-radius: 0 50% 50% 50%;
}

None of the three know they’re being rotated: --tile-rot is one of four values, picked once per cell by which quarter of the two-by-two block that cell sits in, same rule the floor downstairs runs on.

Why the shape has to be symmetrical

Star is a hard-stop linear-gradient() split into two triangles, colour and accent, rotated by the same rule as the rest. Four of them together are already a diamond. Repeat that across the floor above and it’s the bold windmill half the real catalogues sell as their signature print.

Star
.cell--star { background: linear-gradient(135deg, var(--tile-color) 50%, var(--tile-accent) 50%); }

Bloom and Weave don’t bother rotating at all. Bloom stacks a small centre dot, a ring around it, and four more dots at the tile’s edges, symmetric on every axis, so --tile-rot does nothing to it on purpose.

Bloom
.cell--bloom {
  rotate: 0deg;
  background:
    radial-gradient(circle at 50% 50%, var(--tile-accent) 0 12%, transparent 13%),
    radial-gradient(circle at 50% 0%, var(--tile-accent) 0 22%, transparent 23%),
    radial-gradient(circle at 100% 50%, var(--tile-accent) 0 22%, transparent 23%),
    radial-gradient(circle at 50% 100%, var(--tile-accent) 0 22%, transparent 23%),
    radial-gradient(circle at 0% 50%, var(--tile-accent) 0 22%, transparent 23%),
    radial-gradient(circle at 50% 50%, transparent 0 40%, var(--tile-color) 40% 68%, transparent 68%);
}

Weave is the densest one, a lattice of arcs and squares meeting at each tile’s corners and centre. Where tiles meet, the arc on one continues the arc on its neighbour and the eye reads the seam as one continuous line. No lucky alignment, just a shape symmetrical enough that touching edges always agree.

Weave
.cell--weave {
  rotate: 0deg;
  background:
    linear-gradient(var(--tile-accent), var(--tile-accent)) 0% 0% / 22% 22% no-repeat,
    linear-gradient(var(--tile-accent), var(--tile-accent)) 100% 0% / 22% 22% no-repeat,
    linear-gradient(var(--tile-accent), var(--tile-accent)) 0% 100% / 22% 22% no-repeat,
    linear-gradient(var(--tile-accent), var(--tile-accent)) 100% 100% / 22% 22% no-repeat,
    linear-gradient(var(--tile-accent), var(--tile-accent)) 50% 50% / 22% 22% no-repeat,
    radial-gradient(circle at 0% 0%, transparent 0 38%, var(--tile-color) 38% 50%, transparent 50%),
    radial-gradient(circle at 100% 0%, transparent 0 38%, var(--tile-color) 38% 50%, transparent 50%),
    radial-gradient(circle at 0% 100%, transparent 0 38%, var(--tile-color) 38% 50%, transparent 50%),
    radial-gradient(circle at 100% 100%, transparent 0 38%, var(--tile-color) 38% 50%, transparent 50%);
}

Strip away every other motif and it’s obvious with nothing else in the frame: one Weave tile alone, then the same tile sixteen times over, touching.

One tile, then sixteen
One tile
Sixteen, touching

One tile alone is already a tidy little cross, four petals meeting a square. Between tiles, two petals from neighbours fuse into one continuous lens, a shape that never existed inside either tile on its own. That’s the payoff of a symmetrical edge: some of the pattern only exists once tiles are touching.

Same story with the other three: Fan, Lens, Wedge and Star still draw hard lines where one tile ends and the next begins, diamonds and windmills stacked in neat rows. Only Bloom and Weave ever needed to be that considerate about their edges.

Painting across the seams

Some hydraulic tile catalogues go further than matching edges: a small star or rosette sits exactly where four tiles meet, bridging the seam on purpose instead of just agreeing across it. Painting something like that means knowing precisely where every tile’s corner lands, and that gets hard the moment the columns stop being even.

subgrid solves it without any of that math. Give an element the same span as the whole floor, set grid-template-columns: subgrid and grid-template-rows: subgrid, and it inherits the floor’s own resolved track lines instead of drawing its own. Put a star where two of those lines cross and it sits on the real seam. Not a percentage guess at where the seam probably is:

A rosette, painted across the seams with subgrid
.floor { display: grid; grid-template-columns: repeat(4, 1fr); }

.overlay {
  position: absolute; /* out of grid placement: the 12 tiles need those cells too */
  inset: 0;
  grid-column: 1 / -1;
  grid-row: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  grid-template-rows: subgrid;
}

.star {
  grid-column: 1 / 3;
  grid-row: 1 / 3;
  justify-self: center;
  align-self: center;
}

There’s a second trap hiding in that code. The overlay spans the whole grid, grid-column: 1 / -1; grid-row: 1 / -1, so subgrid has parent tracks to inherit from. But as an ordinary grid item, that same span claims every cell for itself. The twelve tiles have no grid-column or grid-row of their own, so auto-placement just finds them a home, but if the overlay already owns every cell, it pushes all twelve tiles into brand new implicit rows with no height defined. The floor collapses. position: absolute fixes it by pulling the overlay out of grid placement entirely, so it stops competing with the tiles for cells, while grid-column and grid-row still work to size the box and tell subgrid which lines to read.

That’s the whole trick. Nothing about the star is manually positioned. Subgrid doesn’t guess at where the seam sits. It reads the same lines the tiles are already drawn from. Flip on the grid lines above and check for yourself: every star sits dead center on a line crossing, edges and corners included.

The cost of colour

The four pigments
Cheap
Pricier

The palette isn’t arbitrary either. A hydraulic tile is cast from white cement and marble dust tinted with mineral pigment, the cream field is that material’s own colour before any pigment goes in. Iron oxide did most of the tinting: cheap, dug straight from the ground, which is why red and mustard ochre dominate the era’s catalogues. Cobalt and chromium only became commercially viable pigments in the early 1800s and stayed pricier for decades.

Those four pigments, , , , , are exactly what’s driving the demo above. Pick red or yellow and you’re building the floor most households could order. Pick blue or green and you’re paying for it, the first thing anyone saw walking through the door.

The formula, in pure CSS

That’s the palette settled. The pattern itself is still about position, and CSS has picked up real trigonometry recently: sin(), cos(), atan2(), all usable straight inside calc(). The tile mould itself still only knows four positions, but the grid holding that tile doesn’t have to stop there.

Lens, rotated by atan2()
Drag, or focus and use the arrow keys, to move the centre
.cell {
  --dx: calc(var(--col) - var(--cx));
  --dy: calc(var(--row) - var(--cy));
}
.cell::after {
  content: '';
  position: absolute;
  inset: 15%; /* 70% size, shrunk so no rotation overflows into a neighbour */
  background: var(--tile-color);
  border-radius: 50% 6% 50% 6%;
  rotate: calc(atan2(var(--dy), var(--dx)) + 90deg);
}

Same border-radius: 50% 6% 50% 6% lens shape as before, just shrunk to 70% of its cell so no rotation angle ever pushes a corner past the edge into a neighbour, the exact overflow a continuously rotating Fan can’t avoid. The only thing that actually changed is rotate: instead of a four-value lookup keyed off col % 2 and row % 2, it’s atan2(dy, dx) plus a quarter turn, computed fresh for every cell from its distance off the grid’s centre, in the stylesheet, no canvas involved. The tile downstairs never needed that. The grid holding it clearly can carry it.

Rotation isn’t the only thing atan2() and friends can drive. The grid’s track sizes can run through sin() too, so instead of nine equal columns, each one breathes wider or narrower following the same wave.

My first attempt used fr units, calc(1fr + 0.4fr * sin(...)), and every column silently collapsed into one single track. Browsers don’t reliably resolve calc() once a flexible fr unit and a trig function are both inside it: the whole declaration gets treated as invalid and the grid falls back to one implicit column. Percentages don’t have that problem. calc(11.1111% + 4% * sin(...)) resolves exactly like it should, a real, different pixel width per column, computed live in the browser.

The grid itself, warped by the same maths
.floor {
  grid-template-columns:
    calc(11.1111% + 4% * sin(calc(0 * 40deg)))
    calc(11.1111% + 4% * sin(calc(1 * 40deg)))
    calc(11.1111% + 4% * sin(calc(2 * 40deg)))
    /* … six more, same pattern */;
}

Nine unequal columns, six unequal rows, no two the same width. Every one of those widths is a live sin() call sitting directly inside grid-template-columns, not a wrapper faking it with a transform. The track sizes themselves are doing the maths this time, not just what’s rotating inside them.

Flip the toggle above and watch it happen: the same grid, the same maths, just fr swapped in for %, and every column collapses into one.

That’s still what pulls me back to this pattern, the same one that got me studying graphic design. No hydraulic tile ever needed a column to bend. The grid holding it can anyway.