Function

@tile

Cuts a shape into pieces and gives each cell one of them, as a value for clip-path. When every cell covers the whole doodle, the pieces fit back together into a mosaic.

Syntax

  • @tile([<preset>; ]<commands>)
  • @tile.<kind>([<preset>; ]<commands>)
  • @tile.x, @tile.y, @tile.xy, @tile.k, @tile.r

The shape is a preset from the @shape property, such as heart or circle, or @shape commands that draw one, text and path included, so @tile.hex(text: O; gap: 1) fills a letter with hexagons. It defaults to square. The kind picks how the shape is cut; @tile on its own is @tile.slice. The commands are listed in the table below.

The kinds

Each one below cuts the default square into 30 pieces, and its name links to the section that covers it:

One piece per cell

Each cell gets the piece at its index, clipped from the whole doodle, so every cell has to cover the doodle. @tile sees to that: it adds @place: center and @size: 100%, leaving out whichever of the two the doodle sets itself. gap leaves space between the pieces, in percent of the element, and the doodle's background shows through it:

@grid: 24x1 / 60vmin / #111;
background: @p(
  #f2efe6, #f2efe6, #f2efe6,
  #d7301f, #1f4e9c, #f5c400
);
clip-path: @tile(gap: 1.5);

Without gap, neighboring pieces overlap by a hair, as with gap: -.3, so the background doesn't show as thin lines where their edges meet. gap: 0 makes them meet exactly, for see-through colors that would show the overlap, and a lower value overlaps them more.

The number of cells is the number of pieces. Their rows and columns don't matter, since every cell covers the doodle, so the examples here put them in one row: @grid: 60x1 gives 60 pieces, where @grid: 60 would give 3600.

points sets a smaller number of pieces, and the cells left without one show nothing. It counts pieces, not the outline's corners as in @shape, so leave it out of a body copied from there.

On a doodle that isn't square, the pieces keep their shape when @grid gives both lengths in one unit, as in 12rem 6rem:

@grid: 60x1 / 12rem 6rem / #3b2410;
background: hsl(@r(34, 44) 92% @r(52, 64)%);
clip-path: @tile.hex(gap: 1.2);

Otherwise they are laid out on a square, so on a doodle twice as wide as it is tall they come out twice as wide too. That happens when the size comes from CSS, mixes units or uses percentages, or when the cells have an @size other than 100%. aspect gives the element's proportions then, as one number or a fraction such as 2 / 1, and wins over the one from @grid.

Slices

@tile.slice starts from the shape's bounding box and keeps cutting a random piece in two, across its longer side. Bigger pieces are more likely to be cut. The cuts are level or upright; spread lets them tilt by up to half that many degrees either way. Each piece here is filled with stripes at its own angle:

@grid: 30x1 / 60vmin;
background: repeating-linear-gradient(
  @r(180)deg, #000 0 2px, #fff 0 6px
);
clip-path: @tile(spread: 90);

density works here too: pieces where it is larger are cut more often, so they end up smaller.

Around scattered points

@tile.voronoi spreads points inside the shape, the same points as @plot.scatter, and gives each point the region closer to it than to any other. The regions fill the whole box, not only the shape:

@grid: 60x1 / 60vmin / #1b1b1b;
background: @p(
  #264653, #2a9d8f, #e9c46a, #f4a261, #e76f51
);
clip-path: @tile.voronoi(gap: 1);

@tile.delaunay joins the same kind of points into triangles and keeps the ones inside the shape. For these two kinds, points is the number of points, not pieces. delaunay starts from about half as many points as cells, since there are about twice as many triangles as points. When there are still more triangles than cells, the largest ones on the outside go first, so the inside keeps no holes:

@grid: 60x1 / 60vmin;
background: #000;
clip-path: @tile.delaunay(circle; points: 9; gap: 1);

density is an expression in x and y, the -1 to 1 space with y pointing up. More points land where it is larger, so the pieces get smaller there:

@grid: 120x1 / 60vmin / #000;
background: #fff;
clip-path: @tile.voronoi(density: 1 - y; gap: .6);

Where the points land depends on the doodle's seed, or on the seed command when given, so the same seed gives the same pieces, as with slice and circle.

The points are evened out in 10 rounds. relax sets how many, up to 50; relax: 0 leaves them where they fell, so the pieces vary more in size and shape.

Shaping the regions

crack groups the regions of @tile.voronoi into larger ones, about that many regions each. With two values, gap sets the space between the groups and then the space inside them; the second defaults to a quarter of the first. @tile.k is the group's, so a group shares a color:

@grid: 300x1 / 60vmin / #1b1b1b;
clip-path: @tile.voronoi(crack: 12; relax: 0; gap: 1.6 .3);
background: oklch(.8 .1 calc(@tile.k * 72));

stretch makes the regions that many times longer, along an angle that turns clockwise, unlike the counterclockwise rotate of a shape. stretch: 3 lies level, stretch: 3 90 stands upright:

@grid: 160x1 / 60vmin / #0c1d1f;
background: linear-gradient(#5fd3c7, #155e75);
clip-path: @tile.voronoi(stretch: 2.5 90; round: 1; gap: .7);

Lattices

@tile.grid, @tile.hex, @tile.triangle, @tile.cube and @tile.penrose lay a regular tiling over the shape. They pick the largest piece size that fits the cells and keep every piece that reaches into the shape, so the edge is jagged:

@grid: 60x1 / 60vmin / #3b2410;
background: hsl(@r(34, 44) 92% @r(52, 64)%);
clip-path: @tile.hex(circle; gap: 1.2);

Cells left over show nothing. The lattice is centered on the doodle and moves half a step only when that fits about half a row more pieces, so adding a cell rarely shifts it.

grid is a grid of squares, cube splits each hexagon into the three faces of a cube, and penrose is a Penrose tiling of thin and thick rhombs. A lattice depends only on the shape and the count, so seed does not change it, unless it has jitter or its edge calls random() or @R.t.

shift slides every other row of a grid by that part of a square, so .5 lays bricks. With two values, shift: 0 .3, the first is zero and every other column slides down instead. Whole numbers change nothing:

@grid: 60x1 / 60vmin / #eee6dc;
background: hsl(@R(120, 240, 2, 8) 60% 50%);
clip-path: @tile.grid(shift: .5; gap: .8);

jitter moves every corner of a lattice in a random direction, by up to that part of a piece's size. Neighbours move the shared corner together, so the pieces still fit. Above about .3, pieces start to fold over each other. It follows the seed.

@plot.hex, @plot.grid and @plot.penrose give the centers of these pieces as points instead, keeping only the pieces that sit inside the shape.

Bending the edges

edge bends every edge of a grid, hex or triangle lattice along a curve. It is an expression in t, which runs from 0 to 2π along the edge, and its value pushes the edge sideways, as a part of the edge's length. Both neighbours of an edge share the same curve, so the pieces still fit together:

@grid: 40x1 / 60vmin / #222;
background: hsl(@r(360) 60% 70%);
clip-path: @tile.grid(edge: .15 * sin(t); gap: .6);

The curve is tilted so it starts and ends on the corners of the edge, whatever its value is at 0 and 2π. On a triangle only the part that looks the same turned half around the middle of the edge is kept, so sin(t) keeps its shape but a bump like sin(t / 2) flattens out.

e is the direction of the edge. On a grid, 1 is level and 2 upright, so this bends only the upright edges:

@grid: 40x1 / 60vmin;
background: @p(#0f4c5c, #e36414, #fb8b24, #9a031e);
clip-path: @tile.grid(edge: (e - 1) * .12 * sin(t));

On a triangle, 1 is level; on a hex, 2 is upright. The other values are the two slanted directions.

random() is drawn once per edge, so every edge gets its own curve, like the cuts of a jigsaw puzzle. It follows the doodle's seed, or the seed command:

@grid: 60x1 / 60vmin / #1b1b1b;
background: hsl(@r(190, 230) 55% @r(45, 70)%);
clip-path: @tile.hex(
  edge: .25 * sin(t) * (random() - .5);
  gap: .5
);

@R.t gives every edge a wobble of its own instead of one curve made larger or smaller. The noise follows the seed too:

@grid: 40x1 / 60vmin / #222;
background: hsl(@r(360) 60% 70%);
clip-path: @tile.grid(edge: @R.t(-.2, .2); gap: .6);

x and y are the middle of the edge, in the -1 to 1 space with y pointing up, so the curve can change across the shape. Here the edges are straight in the middle and wavier towards the sides:

@grid: 80x1 / 60vmin;
background: #000;
clip-path: @tile.triangle(
  edge: .12 * x * sin(2t);
  gap: .5
);

The expression can read other commands, as in a: .1 * x; edge: a * sin(2t). edge has no effect on cube, penrose or the other kinds, nor with op or star.

slide moves the points of an edge along it instead of across it, as a part of the edge's length. It reads the same values as edge. Together they draw curves that fold back, which edge alone cannot:

@grid: 40x1 / 60vmin / #222;
background: hsl(@r(360) 60% 70%);
clip-path: @tile.hex(
  edge: .15 * sin(t);
  slide: .2 * sin(t / 2)^2;
  gap: .6
);

Each edge is matched with the opposite edge of its neighbour, so every piece is the same shape, moved. pair: turn matches it with the next edge around a corner instead, as in Escher's lizards: every piece is the same shape, turned. @tile.k tells the turns apart, in steps of 120° on a hex and 90° on a grid, and e numbers the pairs of edges. It works on hex and on grid without shift:

@grid: 48x1 / 60vmin / #222;
clip-path: @tile.hex(
  pair: turn;
  edge: .3 * sin(t / 2)^2 * (e - 2) + .1 * sin(t);
  gap: .5
);
background: oklch(.78 .11 calc(@tile.k * 120));

Operators

op turns the pieces into a new tiling, one step per letter. The letters come from Conway's polyhedron notation. op: t cuts every corner off, so the squares of a grid become octagons with small squares between them:

@grid: 100x1 / 60vmin / #222;
clip-path: @tile.grid(op: t; gap: .8);
background: @match(@tile.k == 1, #ebbf4d, #e6437d);

It works on grid, hex, triangle, cube, penrose and voronoi. Here is every letter on a hex; click one to open it in the playground:

The colors come from @tile.k, which tells the new shapes apart. Pieces grown from the old ones keep their number, 1 in yellow. Pieces at the old corners get the next number, 2 in pink, and the pieces that e, b and s put along the old sides get the one after, 3 in white. d makes every piece 1, and k keeps the number of the piece it splits.

Letters combine, and run right to left as in Conway's notation: op: ds applies s first, then d. On a grid that gives the Cairo tiling of pentagons, which g writes in one letter:

@grid: 80x1 / 60vmin / #222;
clip-path: @tile.grid(op: g; gap: .8);
background: hsl(@r(190, 230) 55% @r(45, 70)%);

The letters table at the end describes every step in full. A few more details:

  • points counts the pieces after the last step.
  • On grid, hex and triangle, every letter but k, g, m and o keeps all sides the same length.
  • Other letters are skipped, so tQ copied from a list of tilings works as t.
  • On a voronoi, op does nothing when crack or stretch is set.

Stars

star draws a star inside every piece, the way Islamic star patterns are drawn. Two lines leave the middle of each side at the given angle, between 0 and 90 degrees, and meet inside the piece. Small angles give fat stars and large ones thin stars. The space left around each corner becomes a piece of its own, in pink here:

star works on the same kinds as op and comes after it, so the two combine. Each star keeps the @tile.k of its piece and the corner pieces get the next number. On the octagons and squares of op: t, 67.5 gives eight-pointed stars and crosses, with the corner pieces 3 in white:

@grid: 150x1 / 60vmin / #000;
clip-path: @tile.grid(op: t; star: 67.5; gap: .5);
background: @match(@tile.k == 3, #fff, #e6437d);

Packed circles

@tile.circle packs circles into the shape, largest first, and returns each as an ellipse(). The shape can be drawn with @shape commands. Here r: cos(2t) is a four-petal rose:

@grid: 160x1 / 60vmin;
background: red;
clip-path: @tile.circle(r: cos(2t); gap: .4);

size limits how big a circle can grow where it is centered, in average radii, 1.5 by default. A rare few may grow larger, so the largest step down in size rather than all stopping at one. A single circle fills the shape. It is an expression in x and y, and 0 leaves no circle centered there.

Round corners

round turns the corners of every piece into curves of this radius, in the -1 to 1 space, as far as the edges allow, so a radius as large as the pieces leaves no straight edge at all. It works on every kind except circle, after the gap is taken. A rounded piece comes out as a CSS shape() with true curves, not a polygon():

@grid: 100x1 / 60vmin / #2d2a26;
background: hsl(30 8% @r(62, 80)%);
clip-path: @tile.voronoi(round: 1; gap: 1.4);

Reading the piece

Once a cell has its piece, @tile.x and @tile.y give the piece's center, as percentages of the element. They make good amounts for color-mix():

@grid: 60x1 / 60vmin;
clip-path: @tile.voronoi(gap: 1);
background: color-mix(in oklch, #264653 @tile.y, #e9c46a);

In an expression the percent sign is dropped, so @match(@tile.x < 50, …) picks the pieces on the left half.

A little randomness on top of the height shades the triangles like a low-poly model:

@grid: 100x1 / 60vmin;
clip-path: @tile.delaunay(circle);
background: color-mix(in oklch,
  #0b2545 calc(@tile.y + @r(-12, 12)%), #b9d6f2
);

@tile.xy gives both as x y, the way a position is written, as in a gradient's at. Each region here is lit from its center:

@grid: 60x1 / 60vmin / #000;
clip-path: @tile.voronoi(gap: .6);
background: radial-gradient(8% 8% at @tile.xy,
  #fff 15%, #48cae4 30%, #023e8a
);

@tile.k tells pieces apart. In cube, 1 is the top face, 2 the left and 3 the right:

@grid: 90x1 / 60vmin;
clip-path: @tile.cube();
background: oklch(calc(1 - @tile.k * .22) .05 250);

In penrose, 1 is a thin rhomb and 2 a thick one:

@grid: 150x1 / 60vmin;
clip-path: @tile.penrose(gap: .4);
background: color-mix(in oklch,
  #00539c calc((@tile.k - 1) * 100%), #ffd662
);

In voronoi, no two neighboring regions share a kind, so coloring by kind never puts one color next to itself:

@grid: 100x1 / 60vmin;
clip-path: @tile.voronoi(gap: .5);
background: oklch(.75 .12 calc(@tile.k * 72));

@tile.r is the radius of the largest circle about the piece's center that fits inside it, which for a circle piece is its own radius. Bigger circles are redder here:

@grid: 150x1 / 60vmin;
clip-path: @tile.circle(circle; gap: .6);
background: color-mix(in oklch,
  #d00000 calc(@tile.r * 8), #ffb3c1
);

These read the cell's last @tile. At the top level a rule may read them before the rule that makes the piece. In a cell without a piece they are empty.

Moving the pieces

In clip-path, @tile also sets the cell's transform-origin to the center of its piece. So scale, rotate and their animations work on each piece in place:

@grid: 40x1 / 60vmin;
background: #000;
clip-path: @tile.hex(circle);
animation: pulse 2s infinite alternate;
animation-delay: @r(-2s);
@keyframes pulse {
  to { scale: .3 }
}

Reference

Kinds

KindPiecesKind values
slicethe bounding box cut by straight lines (the default)1
voronoiregions around points scattered inside the shape, filling the box1, 2, 3… (usually up to 5), never the same as a neighbor; with crack, the group's
delaunaytriangles between scattered points, inside the shape1
grida grid of squares1
hexa grid of hexagons1
trianglea grid of triangles1 pointing down, 2 up
cubehexagons split into three rhombs1 top, 2 left, 3 right
penrosea Penrose tiling of rhombs1 thin, 2 thick
circlecircles packed inside the shape, largest first1

Commands

CommandKindsMeaning
gapevery kindspace between pieces, in percent of the element; a negative value overlaps them. Default -.3, so no background shows along the edges, or 0 for circle. With crack, a second value sets the space inside a group, by default a quarter of the first, or the same when it is negative.
aspectevery kindthe element's width over its height, as 2 or 16 / 9, so pieces keep their shape. Defaults to the proportions of @grid's size when both lengths share a unit other than % and the cells have no other @size than 100%; otherwise 1.
pointsevery kindat most this many pieces; for voronoi and delaunay, the number of points. Defaults to the number of cells, or about half of it for delaunay; a larger value changes nothing.
seedevery kinda fixed seed for everything random in the tiling, random() and @R.t included. Defaults to the doodle's seed.
roundevery kind but circlecurves the corners with this radius, in the -1 to 1 space, so the piece is a shape()
shape commandsevery kindr, rotate, scale, move and the rest of @shape's commands change the shape being cut. frame is ignored, and round rounds the pieces, not the shape.
spreadslicehow far a cut may tilt, in degrees, half of it each way. Default 0.
densityslice, voronoi, delaunaywhere pieces are smaller, as an expression in x and y
relaxvoronoi, delaunayrounds that even out the points, up to 50. Default 10; 0 keeps them random.
crackvoronoigroups the regions, about this many each
stretchvoronoi<ratio> [<angle>], regions this many times longer along the angle (degrees, clockwise), up to 1000
shiftgridslides every other row by this part of a square; 0 <n> slides every other column instead. Default 0.
jittergrid, hex, triangle, cube, penrosemoves each corner by up to this part of a piece's size. Keep it below about .3.
edgegrid, hex, trianglebends each edge sideways, as a part of its length. An expression in t (0 to 2π along the edge), e (its direction) and x, y (its middle); random() and @R.t differ from edge to edge. No effect with op or star.
slidegrid, hex, trianglemoves each edge's points along it, as a part of its length. The same values as edge.
pairgrid, hexturn matches each edge with the next one around a corner, so the pieces are one shape turned by @tile.k. Not on a grid with shift.
opgrid, hex, triangle, cube, penrose, voronoibuilds a new tiling, one step per letter, applied right to left; see the letters. Not on a voronoi with crack or stretch.
starthe same as opturns every piece into a star, with lines leaving each side's middle at this angle, in degrees between 0 and 90. Applied after op.
sizecirclethe largest radius where a circle is centered, in average radii, as an expression in x and y. Default about 1.5, with a few larger.

A number may be written as math in the variables of the body, as in k: .1; jitter: 2k, except in gap, stretch and star. // starts a comment that runs to the end of the line.

Operator letters

LetterStep
dthe dual: one piece for each corner, joining the centers of the pieces around it
acuts every corner off, down to the middle of each side
tcuts every corner off
ksplits every piece into triangles around its center
epulls the pieces apart, with a square along each side and a piece at each corner
bt and e at once
spulls the pieces apart with a twist, with triangles in the gaps
gshort for ds, the dual of s
jshort for da, the dual of a
mshort for kda, k of j
oshort for de, the dual of e

Values of the cell's piece

FunctionValue
@tile.x, @tile.ythe center of the piece, in percent of the element
@tile.xythe same center as x y
@tile.kwhich kind of piece it is, from the table above
@tile.rthe radius of the largest circle about the center that fits in the piece, in percent of the element's shorter side

See also