What's Included
Code snippet omitted: it ships with the download.
No build step, and no images to source. Open index.html and it runs.
Quick Start
- Open
index.htmland replace Daymark with your product: the brand, hero dashboard, project-room widgets, metrics, product details and conversion copy. - Change the core colours and optional spot colours at the top of
assets/style.css. Keep the RGB triples in step. See Brand directions. - Rearrange the grid by changing tile spans in
style.css(search forBoard tile spans). Tiles are placed by span, not by fixed grid lines, so there is no markup to rewrite. See The grid. - Update the
<title>and<meta name="description">, and remove<meta name="robots" content="noindex, nofollow">when you go live. - Swap the fonts if you want to: change the Google Fonts
<link>inindex.htmland the three--font-*tokens instyle.css. - Deploy the folder anywhere that serves static files.
Optional: minify before you go live
assets/script.js is the readable source and is what index.html loads. It is around 43KB, which is fine to ship as-is. If you would rather serve it minified, produce it yourself once your edits are done:
Code snippet omitted: it ships with the download.
Then point the last <script> tag in index.html at assets/script.min.js. Keep script.js as the file you edit and re-run the command after each change, otherwise the two quietly drift apart.
The grid
Two bento clusters, each a plain CSS grid. Tiles carry a span, never a fixed grid line, so the browser places them and re-places them when one of them takes the full row:
Code snippet omitted: it ships with the download.
Two things are worth knowing before you rearrange them:
- The cells should add up. A four-column grid wants its spans to fill whole rows, or the last row ends with a visible gap of ground. The markup order matters here too, because the tiles auto-place in document order; the amber tile sits before the halftone plate in
index.htmlfor exactly that reason. - Give neighbours genuinely different spans. A 2x2 next to a 1x1 next to a 3x1 is what makes a bento read as a bento. Six identical tiles read as a card grid, and no amount of colour fixes that.
The seams are per-tile: every tile draws its own 1.5px rule and the grid holds them apart with a 3px gutter of bare ground. That is why the seam weight is identical between tiles and at the outer edge, and why a tile that opens elsewhere leaves ground behind rather than a stray filled panel.
The draggable boards (the signature)
The hero carries data-reorder-board; the practical builder carries data-workspace-board. With JavaScript available, the script adds a handle button to every direct widget and attaches Draggable to that handle only, so links, checklist buttons and selectable copy retain their native behaviour.
Raw pointer coordinates and elementsFromPoint() identify the widget under the handle, including on touch. On release the real DOM node moves before or after that target, then Flip.from() animates the full CSS Grid into its new order. There is no duplicate visual layer and the changed reading order is real.
Both boards lock min-height before Flip's absolute phase, tween it to the new measured height and clear it only after Flip completes. This prevents the board collapsing and the following section sliding underneath during a reorder. The project room also has a reset control that restores the authored widget order.
Focus any handle and press an arrow key to move its widget one place. An aria-live region announces every move. Under reduced motion all controls still work, but settle durations become zero.
The tile expansion
An expandable tile is a data-tile name, a matching data-tile-detail panel inside it, and a real <button>:
Code snippet omitted: it ships with the download.
To add one, copy the whole article and change four things together: data-tile, data-tile-detail, the panel's id, and the button's aria-controls. The pairing is by name, not by position, so deleting a tile cannot shift the rest out of step. Any tile without a data-tile attribute is simply not expandable, which is how the art, quote and monogram tiles work.
| Attribute | Goes on | What it does |
|---|---|---|
data-tile-grid |
The grid container | Marks a grid whose tiles can expand. Flip runs per grid. |
data-tile="name" |
A tile | Makes it expandable, under that name |
data-tile-detail="name" |
A panel inside that tile | The content revealed. Must match the tile's name. |
data-tile-toggle |
A <button> inside that tile |
The open/close control |
data-btn-open / data-btn-close |
Spans inside the button | Swapped by CSS on open, so one button carries both labels |
Inside the panel, the script staggers whatever direct children it finds, so the markup is free: detail-head, detail-copy, detail-figs and detail-list are styles, not requirements.
One tile is open at a time, across both grids. Closing happens on the control, on a click anywhere outside the tile, or on Escape.
Two things the script handles that are easy to get wrong if you rebuild it:
- The grid's height. Flip's
absolute: trueis what lets a tile travel across rows, and it lifts the animating tiles out of the grid's normal flow, so the grid's height would collapse toward zero mid-tween and everything below the grid would jump, then snap back. The script locks the grid'smin-heightto its pre-tween height, tweens that to the post-layout height over the same duration, and clears it on complete. - The detail content waits. It fades and staggers in after the morph lands. Run it during the tween and the text reflows inside a box that is still changing width, which reads as a wobble.
The stat board
Each figure ships as plain text in the markup. The script splits it into per-character cells:
Code snippet omitted: it ships with the download.
| Attribute | Goes on | What it does |
|---|---|---|
data-flap-board |
The section | Marks the board. Also scopes the replay button. |
data-flap |
Each stat | Marks a figure to split into cells |
data-flap-value |
The figure element | The characters to roll to. Falls back to the element's text. |
data-flap-replay |
A <button> |
Replays the whole board |
Values are uppercased, and any character in 0-9 A-Z % + - . , rolls. Anything else is set once and left alone rather than rolled to something wrong.
The figure in the markup is the accessible one. Once the cells exist they are aria-hidden, and the authored figure becomes visually hidden but stays in the DOM, so a screen reader reads "86%, of work from returning clients" rather than a stream of rolling characters. It is also why the board is correct with JavaScript off, with a blocked CDN, and under reduced motion: the number on the page was never generated.
Keep the total number of cells to roughly forty. FLAP_MAX_CELLS at the top of script.js caps the animated ones; past that the per-frame text writes cost more than they read.
Reveals
| Attribute | Goes on | What it does |
|---|---|---|
data-intro-group |
A container | Its children land tile by tile on load |
data-reveal |
Any element | Fades and rises when it scrolls into view |
data-reveal-group |
A container | Its children fade and rise, staggered, when they scroll into view |
data-hero-fade |
Any element | Fades in late in the load-in sequence |
data-line |
A line inside a .mask |
Rides up from below, for the masked headline |
Every one of these has a start state pre-set in CSS under .has-js, which is added synchronously in the <head>. That ordering is what stops the flash where an element paints visible and then snaps to invisible as it crosses its trigger. When a reveal finishes the script adds is-shown, which is what makes the start state one-way: if the media conditions ever change mid-visit, GSAP reverts the inline styles it wrote and without that class the page would hide itself again.
If you add a reveal hook of your own, add it to the pre-hide block near the top of style.css and to the prefers-reduced-motion block at the bottom that restores it.
Brand directions
Four complete directions ship in style.css. They change colour, typography, corner treatment, grid spacing, border weight and depth—not only the accent:
| Direction | Attribute | Character | Display face |
|---|---|---|---|
| Daymark | no attribute | Balanced four-column board, restrained stone and navy, compact radii and subtle offset depth | Manrope 700, a geometric-humanist sans |
| Ledger | data-palette="ledger" |
Dense twelve-column operations sheet, square cells, green-led and shadowless | Zilla Slab 700, a slab serif |
| Signal | data-palette="signal" |
Full-width console headline above live modules, dark grid ground and monospaced display type | IBM Plex Mono 600, the mono |
| Editorial | data-palette="editorial" |
Asymmetric eight-column feature layout, borderless lead story, serif headlines and generous section rhythm | Newsreader 600, a news serif |
The face and the colours are independent tokens (--font-display and --font-display-weight sit in each block next to the colours), so keep the direction and swap the face, or the other way round. All four faces are in the Google Fonts <link> in index.html; a browser only downloads the ones the page uses, but once you have settled, trim the link to the families you kept. The body face is the same in every direction except Ledger, which sets Manrope for reading text; the mono face never changes.
Choose one on the root element:
Code snippet omitted: it ships with the download.
These are different compositions, not skins: each direction changes the hero grid hierarchy, workspace columns, navigation treatment and statement layout in addition to its type, shape and colour system. Reordering remains real in every direction because widgets still use spans and auto-placement rather than fixed grid areas.
The picker visible on the GSAP Vault preview is demo-only and is not included in the download. The four CSS directions do ship. Once you choose one, either keep the attribute or copy that block's values into :root and remove the unused alternatives.
Core palette tokens
Each direction restates the core roles and RGB triples below. Translucent values resolve from those triples, so no switched brand inherits a stray colour from Daymark.
Code snippet omitted: it ships with the download.
Keep the triples in step with the hexes above them; every translucent value in the stylesheet resolves from them, so there is no rgba() literal anywhere that a reskin could leave behind.
The warm-white tile and stone ground are deliberately close; depth comes from the solid ink edge and offset shadow rather than from a murky surface step. The spot colours are objects in the board, not general-purpose text colours.
Measured, composited, at the sizes used:
| Pair | Ratio |
|---|---|
| ink on stone ground | 13.25:1 |
| ink on warm-white tile | 14.60:1 |
| warm white on navy | 8.00:1 |
| ink on rust | 4.66:1 |
| ink on ochre | 6.95:1 |
| ink on sage | 5.22:1 |
| ink on lavender | 7.62:1 |
| ink at .72 on warm-white tile | 6.13:1 |
Rust carries solid ink and sage secondary copy uses .80 ink; do not reuse the default .72 dim token on those surfaces without re-measuring it. Navy always carries warm-white text. The solid ink borders and navy focus ring clear the non-text 3:1 requirement.
Accessibility
- Every toggle is a real
<button>witharia-expandedandaria-controls, so Enter and Space work without any key handling. Opening moves focus into the panel; closing returns it to the trigger it came from. Escape closes from anywhere on the page. - On a coarse pointer the toggle stretches over the whole tile, so a tap anywhere opens it. On a fine pointer it stays a corner control, so the tile's copy remains selectable.
- The flap cells are
aria-hiddenand the authored figure stays in the DOM as a visually hidden element, so a screen reader gets the value and its label, never a stream of rolling characters. prefers-reduced-motiongets the same layout with no tweens, via both a CSS block and agsap.matchMediabranch. Tiles still open and close, because that is function rather than decoration; they just switch instantly. The replay button on the stat board is hidden, since there is nothing for it to replay, and the figures are already correct.- Without JavaScript every detail panel is simply the next part of its tile and every figure is plain text, so the page reads as one complete document with nothing behind a control that cannot work.
- The
has-jsclass that hides reveal targets is added in the<head>before first paint and removed again by the script if GSAP did not load, so a blocked CDN degrades to that same complete document. - Focus is always visible: a solid amber ring, which clears 3:1 on both surfaces.
Smooth Scrolling
Lenis is wired in and optional. Remove the CDN tag in index.html and the lenis block at the top of script.js to drop it. If you keep it, note the lenis.on('scroll', ScrollTrigger.update) line: without it the tile morph and the reveals run a frame behind the smoothed scroll position.
Customisation Notes
- Brand direction: choose Daymark, Ledger, Signal or Editorial; see Brand directions.
- Type: Daymark uses Manrope, DM Sans and IBM Plex Mono. Each direction carries its own display face (Zilla Slab, IBM Plex Mono, Newsreader; see Brand directions), and Ledger also moves Manrope to the body. Update the Google Fonts
<link>when you swap them. The hero display is capped well under 6rem because the cover tile is only two columns wide; a bigger face starts breaking words. - The art plates are inline SVG in
index.html: a risograph overprint, a halftone field, a plotted line chart, and a duotone monogram. Colours come from the same tokens. Note that on the two pattern plates the fill lives on the<circle>inside<pattern>, because a CSSfillrule on the rect would beat itsfill="url(...)"attribute. - Sections are independent: the script guards every lookup and skips any section it cannot find, so delete what you do not need.
Dependencies
- GSAP 3.15.0 (core)
- ScrollTrigger
- Flip
- Draggable
- Lenis 1.3.17 (optional)
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.