Author a live tile
How do I get those animated scenes on my own pages?
Tiles are JSON, not code: shapes, palette roles, motion, and a cost budget the engine enforces. Write a spec, mount it behind any heading or card.
Typical effort: 30 minutes
The recipe — paste this to your AI
# Make a live tile
Paste-ready recipe. Your site ships a **live-tile engine**: small animated scenes drawn
on a canvas, behind your headings and inside your cards. They are configured as **data**,
not code — a tile is a JSON object describing shapes, and the engine draws it.
This is a lane you own. Adding tiles never takes your site off the upgrade path.
---
## The one-minute version
```ts
// components/my-tiles.ts (components/** is YOUR lane)
import type { TileSpec } from '@/components/visual';
export const MY_TILE: TileSpec = {
v: 1,
id: 'my-tile',
palette: { key: '#e8bd7a', ink: '#8a7460' }, // your colours, by ROLE
colors: { star: '#e8d0b0', moon: '#d0b898', screenTop: '#2a1e18', screenBottom: '#1c1512', screenStroke: '#4a3626' },
layers: [
{ elements: [{ kind: 'glow', x: 0.5, y: 0.35, r: { frac: 0.6, of: 'h' }, color: '$key', alpha: 0.08 }] },
{ elements: [{ kind: 'shape', x: 0.5, y: 0.32, r: { frac: 0.18, of: 'h' }, sides: 6, color: '$key', alpha: 0.4 }] },
{ elements: [{ kind: 'motes', x: 0, y: 0, x2: 1, count: 10, r: 1.6, color: '$key', alpha: 0.25 }] },
],
};
```
```tsx
import { VisualTile } from '@/components/visual';
import { MY_TILE } from '@/components/my-tiles';
<VisualTile spec={MY_TILE} className="rounded-md bg-slate-900">
<div className="flex h-[287px] flex-col justify-end p-6">
<h2>Your heading</h2>
</div>
</VisualTile>
```
That is a working tile: a soft glow, a hexagon, and dust drifting through it.
---
## How a spec is built
**`layers` are depth, back to front.** Each layer holds `elements`. Each element has a
`kind` and a position.
**Positions are FRACTIONS of the tile, never pixels.** `x: 0.5, y: 0.3` is centre,
slightly high — and it stays there on a phone and on a 5K monitor. This is the single
most important habit: a tile authored in pixels breaks at every size but the one you
tested.
**Sizes come off HEIGHT, not width.** `{ frac: 0.2, of: 'h' }`. A hero is short and very
wide; size something off width and it becomes enormous on a desktop.
**Colours are `$tokens`.** `$key` is your main light, `$accent` the saturated highlight,
`$cool` the counter-light, `$warm` warmth in the air, `$ink` structure (lines, not light).
Declare them in the spec's own `palette`. Using tokens means the same tile can wear a
different brand without editing it.
---
## The shapes you have
**Flat shapes** — `shape` (polygon or ellipse: `sides: 0` ellipse, `3` triangle, `6`
hexagon), `rect` (own width, height and `radius`), `arc` (part of a circle, or a pie),
`curve` (a smooth line through points), `silhouette` (a polyline — ridges, skylines),
`burst` (rays from a centre: a sun, a sparkle, a flower), `grid` (a repeating field),
`bars` (a row on a baseline; with `levels` it becomes a meter).
**Solid forms** — `cylinder` and `cone`. These are drawn as real constructions, so they
read as objects with volume rather than as outlines.
**Light** — `glow` (a soft pool), `wash` (a full-frame gradient), `shadow` (the pool
under an object that seats it on a surface).
**Movement** — `motes` (drifting particles; `rise: true` for bubbles or embers),
`confetti` (flakes that tumble), `beam` (a sweeping shaft), `ripples` (expanding rings).
**Making something look like a LIGHT** — set `flicker` (0–1) on a `shape`. A lamp, a bulb
or a sign does not change size, it changes how hard it is burning. Reach for `pulse` only
when the thing really does grow and shrink: on a small shape it moves a fraction of a
pixel and looks static even though every check counts it as motion.
**Ready-made objects** — `mic`, `record`, `arch`, `chairs`, `aisle`, `bulbs`, `pendants`,
`marquee`, `bowl`, `lectern`, `presentation`, `crowd`, and more. Import a shipped spec and
read it; every one is a worked example.
**Combining shapes** — a `group` holds several elements positioned relative to each other
and moves them as one thing:
```ts
{ kind: 'group', x: 0.5, y: 0.3, bob: 0.01, periodS: 6, children: [ /* … */ ] }
```
---
## What makes a tile good
These are not style preferences. Every one of them came from a scene that failed:
1. **One thing a viewer can NAME.** A mic, a lamp, an arch, a sign. Scenes made only of
abstract light read as decoration and nobody knows what they are looking at.
2. **A soft fill reads as LIGHT; only an outline reads as an OBJECT.** If a shape should
be a thing rather than a glow, stroke it — or set `fill` *and* `stroke` with a
`fillAlpha`, which gives you a lit face with a crisp edge from one element.
3. **Give it something to stand on.** A ground line, a counter, a horizon, or a `shadow`.
Objects with nothing beneath them hang in the frame.
4. **Something must TRAVEL.** Motion smaller than a few pixels is invisible — the tile
just looks static. Something should cross real distance, or change brightness hard.
5. **Keep out of the copy.** In a card, your heading owns the bottom half: put the subject
around `y: 0.30`. In a wide hero, the copy owns the middle third horizontally.
6. **No lettering.** Words in the art tie the scene to one moment and one business. Let
the reader's own copy do the talking.
7. **Draw the CONTAINER, not the contents.** This is the same rule as "no lettering", one
level up, and it is the one people break most. A buffet tile drawn as a stack of
pancakes tells a client booking a corporate brunch that you serve pancakes; the empty
chafing dish tells them you serve whatever they want. Draw the aisle with nobody in it,
the mic with no singer, the couch with nobody sitting on it. The specific thing is
always smaller than what the reader would have imagined.
8. **Check your new tile's SILHOUETTE against the ones next to it — before you touch its
colours.** If a scene reads wrong, the cause is usually that it shares a SHAPE with
another tile, and no amount of re-tinting will fix that. Two scenes drawn in the same
shapes are the same scene, however different the subject was in your head.
9. **Anything that must touch the floor has to be measured against the floor's axis.** A
`{ frac, of: 'w' }` size makes an object's HEIGHT a moving fraction of the frame, so it
stands on the ground at one aspect ratio and floats or sinks at every other. If a thing
sits on a surface, size it `of: 'h'` — the same axis the surface is positioned on.
---
## Staying fast
Tiles run in your visitors' browsers, sixty times a second, so the engine bounds them:
- **Counts are clamped** (48 per element) and **arrays are limited** (64 points in a
polyline, 24 children in a group, 60 elements and 12 layers per spec).
- **A cost model** scores fill rate — how many pixels get blended per frame. Big soft
gradients are what actually costs; a hundred thin strokes are free.
- **A page ceiling** covers all mounted tiles together, because six tiles that each pass
on their own is a page nobody checked.
- **One animation loop** drives every tile on the page, and it measures its own frame
time. On a struggling device it sheds density automatically.
- Tiles **pause** when scrolled off-screen or when the tab is hidden, and render a single
still frame for visitors who prefer reduced motion.
Check your own spec before shipping it:
```ts
import { checkCard, checkHero, checkPage } from '@/components/visual';
checkCard(MY_TILE).problems; // a card: cost, composition, and "does it look alive"
checkHero(MY_TILE).problems; // a full-width scene behind a headline
checkPage([{ spec: MY_TILE, w: 477, h: 287 }, /* … */ ]).problems; // a whole page
```
Each returns plain sentences describing what is wrong. An empty array means you are fine.
---
## Cards and heroes are not the same job
Use `checkHero` — not `checkCard` — for anything full width. **The rules are inverted**,
and mounting one on the other's surface is the single most common way a good scene looks
bad:
| | Card | Hero |
|---|---|---|
| shape | ~1.6:1 tile in a grid | ~4.9:1 band across the page |
| copy sits | across the **bottom** | down the **middle third** |
| subject goes | centred, near `y: 0.30` | off to the sides, `x: 0.35–0.65` **clear** |
| needs | one nameable object | a left, a right, and a ground running between |
A card spec stretched into a hero puts its subject directly behind the headline. That is
what "everything looks centred and unprofessional" always turns out to be.
If a card scene is nearly right at width, don't copy it — **derive** it, so there is still
only one of it:
```ts
import { heroFrom } from '@/components/visual';
export const MY_HERO = heroFrom(MY_CARD, {
id: 'my-hero',
front: [{ elements: [/* the horizon and the counterweight the wide frame needs */] }],
});
```
## Telling a story instead of showing a picture
A surface that has to stand for several different things cannot say them in one frame.
A **story** is a list of ordinary specs that cross-fade in sequence — no new format, and
every scene in it is still judged by the same rules:
```ts
import { VisualTile } from '@/components/visual';
import type { TileStory } from '@/components/visual';
const MY_STORY: TileStory = {
v: 1, id: 'an-evening', holdS: 10, fadeS: 3,
chapters: [ARRIVAL, DINNER, THE_BAND, THE_FLOOR], // up to 8
};
<VisualTile story={MY_STORY} className="…">…</VisualTile>
```
Order it the way a customer LIVES it, not the way your services are listed. Costs are
charged on the most expensive **adjacent pair**, since exactly two chapters ever draw at
once — so a heavy scene is fine as long as its neighbours are light. Check with
`storyCost(MY_STORY, 1400, 391)`, and check every chapter with `checkHero`.
---
## Previewing at the real sizes
Your site ships a gallery at **`/play/tile/cards`**. It shows every tile at the sizes they
actually ship at, with a live cost readout.
**Use it before you trust a tile.** A scene composed in a square box and shipped into a
wide one is the most common way a tile goes wrong, and it is invisible until you look at
the real proportions.
---
## Every check can pass and the tile can still be wrong
This is the most important section here, because it is the failure the rest of the tooling
cannot reach.
`checkCard` proves a tile is cheap enough, composed in the right half, and genuinely moving.
All three are **structural**. None of them is *appearance*. On 2026-07-31 five tiles shipped
or nearly shipped with every automated check green, and a person looking at them caught all
five in seconds:
| What was drawn | What it looked like | Why |
|---|---|---|
| A wine glass | A **martini** | Straight walls to a point. Wine bellies out *below* the rim and tapers back in. |
| Four stacked chips | A **spring** | Each disc was translucent, so you saw the far edge of the ones behind it. |
| A stack of food warmers | A **coil** | Same cause: top and bottom ellipse both visible, nothing opaque. |
| A cut lime | A **paper fan** | Three segment lines all met at one point. Fans meet at a point; citrus has crossing chords. |
| A crowd on a lawn | **Nothing**, then **floating balls** | Dark silhouettes on a dark card have nothing to be dark against; then heads without shoulders. |
Four rules fall out of those, and they are worth more than any check:
1. **Check the silhouette before the colours.** Every failure above is a wrong OUTLINE. If the
shape is wrong, no amount of palette work rescues it.
2. **Something opaque has to hide what is behind it.** Stacked or overlapping objects need a
real fill (`fillAlpha: 1`), or you are drawing wireframes and the eye reads a spring, a coil,
a cage.
3. **A silhouette needs a light behind it.** Dark on dark is invisible. Put the dark shape where
the brightest thing on the tile is.
4. **Draw the container, not the contents.** A buffet is chafing dishes, not pancakes — people
picture their own food. A margarita is the glass, not the garnish.
### So the last step is always the same
**Render it and look at it, at the real size, next to its neighbours.**
Not next to a blank page — next to the tiles it will actually sit beside in the grid. "Is this
good?" is the wrong question; **"can I tell this apart from the one next to it?"** is the one
that finds real bugs. Two tiles that share a silhouette are the single most-reported problem in
this catalog's history, and the grid is the only place that shows it.
---
## Replacing a card's art
Cards on your site render through a `kind` string. To take one over — or to add a new one
— put your spec in **`components/visual/specs/cards/site.ts`**, a lane file that ships empty and is yours
to fill. Upgrades never overwrite it, your kind wins over ours if the names collide, and
**`pnpm test` checks your tile the same way it checks ours** — cost, composition and
liveness — so you do not have to remember to.
```ts
// components/visual/specs/cards/site.ts
import { MY_WEDDING_TILE } from './my-wedding';
export const SITE_CARD_SPECS: Record<string, TileSpec> = {
wedding: MY_WEDDING_TILE, // replaces the base one everywhere `wedding` is used
'my-thing': MY_OWN_TILE, // or add a brand-new kind
};
```
A kind with no spec falls back to static art, so you can convert one card at a time and
never have a half-finished page.
---
## Asking an AI to build one
> Read `docs/prompts/make-a-live-tile.md`. Build me a live tile for our **[what it is
> for]** page. The subject should be **[one nameable thing]**. Use our brand colours from
> `site.config.ts`. Keep the subject in the top half — the heading sits at the bottom.
> Make sure something actually travels so it does not look static. Put it in
> `components/visual/specs/cards/site.ts` so the test suite checks it. Then render it at
> `/play/tile/cards` and show me a screenshot at the real size.
---
## The failure that costs the most: two products sharing one `kind`
This one is worth its own section because it has now happened five times on one site, it
never looks like the bug it is, and no amount of art fixes it.
A `kind` is a *scene*, not a *label*. The moment two products point at the same one, you
have two pages that look identical — and the report you get back is never "these share a
spec", it is **"this tile doesn't feel right"** or **"these two look the same"**. So the
instinct is to go and re-tune the shared scene, which cannot possibly work: whatever you
change lands on both.
Real examples, all from the same site:
| symptom reported | actual cause |
|---|---|
| "murder mystery just looks like movie night" | both mapped to `cinema` |
| "foam party should have bubbles" | mapped to `club` — every foam party has a DJ, only a foam party has foam |
| "wine night and bourbon tasting are too similar" | both mapped to `tasting` |
| "monday madness and every game are the same tile" | both mapped to `screens` — **two rows apart on one page** |
| "foam party should have bubbles" *(again)* | a second, hardcoded copy of the mapping in a page file |
### Two rules that prevent all five
1. **Before adding a mapping, grep for the `kind` you are about to use.** If something
else already claims it, you need a new scene — not a palette. Ask what physically
differs between the two products and draw *that*: bourbon and wine are separated by the
GLASS (short and heavy vs tall and stemmed), not by colour.
2. **Keep every mapping in one table.** The fifth row above happened because a journal
page chose its `kind` inline, where no registry could see it. A `kind` picked in page
code cannot be enumerated, cannot be diffed against the others, and fixing one copy
provably does not fix the rest.
### Checking it went live
When you re-point a `kind`, confirm the new one actually resolved:
```js
// in the browser console, on the page you changed
document.querySelectorAll('canvas').length // live tiles
document.querySelectorAll('svg.card-scene').length // static fallbacks
```
A `kind` that does not exist **falls back silently** to the static SVG — by design, so a
typo can never break a page. The cost is that a typo looks exactly like a styling choice.
If the fallback count is not zero, one of your kinds is misspelled.
Source of truth
This recipe ships inside the template as docs/prompts/make-a-live-tile.md — this page renders that exact file. The repository is always the newest version: https://github.com/srisenmay/bringthefront-template. Clone the template and you get every recipe locally, versioned with the release you are on.