
The menu under the page
Menus, 3D, Physics, Transitions
NewFree
The menu of a small candle maker as a box of matches: closed it is the button, pressed it is lifted towards you and the tray slides out of the sleeve in jerks, with the friction of the card, until it knocks against the lip. Each match is a section, its name printed on the stick. Chosen, the hand takes it out, strikes it on the rough strip in a spray of sparks, the head catches in a flare and a small flame drawn every frame warms the box; then it goes out in a thread of smoke and the match goes back to its place burnt: it is the page you are on.
A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The matchbox menu</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/fraunces@5.3.0/full-italic.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/sofia-sans-condensed@5.3.0/index.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="stage">
<!-- the menu of a candle maker as a box of matches. Closed, the button is the box: the printed sleeve, the brown
striker along its sides. Pressed, the box is picked up and the tray slides out of the sleeve; inside lie the
matches, one per section, each with the name printed along its stick. Chosen, a match is taken out, struck on
the side of the box, burns for a moment and goes back into its place burnt: the burnt match is the page you
are on. The flame, the sparks, the smoke and the light are drawn by the script on a canvas -->
<nav class="mb" aria-label="Mothlight" data-matchbox>
<span class="mb__frame" aria-hidden="true" data-frame></span>
<div class="mb__box" data-box>
<button class="sleeve" type="button" aria-expanded="false" aria-controls="mb-sections" data-sleeve data-autoclic>
<!-- the top side of the box, with its striker -->
<span class="sleeve__side" aria-hidden="true">
<svg class="sleeve__grain" viewBox="0 0 196 13" preserveAspectRatio="none">
<defs>
<filter id="mb-sand" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.55 0.7" numOctaves="2" seed="7"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0 0.03 0 0 0 -2.6 1.55"/>
<feComposite in2="SourceGraphic" operator="in"/>
</filter>
<filter id="mb-sand-light" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.8 0.9" numOctaves="1" seed="21"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.86 0 0 0 0 0.66 0 0 0 -3.2 1.62"/>
<feComposite in2="SourceGraphic" operator="in"/>
</filter>
</defs>
<rect width="196" height="13" filter="url(#mb-sand)"/>
<rect width="196" height="13" filter="url(#mb-sand-light)"/>
</svg>
</span>
<!-- the label printed on the sleeve -->
<span class="sleeve__face" aria-hidden="true">
<svg class="label" viewBox="0 0 196 132">
<defs>
<filter id="mb-paper" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.36 0 0 0 0 0.25 0 0 0 0 0.12 0 0 0 0.22 -0.05"/>
<feComposite in2="SourceGraphic" operator="in"/>
</filter>
<radialGradient id="mb-age" cx="0.42" cy="0.4" r="0.75">
<stop offset="0.55" stop-color="#5a3a14" stop-opacity="0"/>
<stop offset="1" stop-color="#5a3a14" stop-opacity="0.2"/>
</radialGradient>
</defs>
<rect class="label__paper" width="196" height="132"/>
<rect width="196" height="132" filter="url(#mb-paper)"/>
<rect width="196" height="132" fill="url(#mb-age)"/>
<rect class="label__rule" x="6.5" y="6.5" width="183" height="119" rx="3"/>
<rect class="label__rule label__rule--fine" x="9.6" y="9.6" width="176.8" height="112.8" rx="1.6"/>
<path class="label__rule label__rule--fine" d="M18 38.5H112"/>
<!-- the moth, drawn to the light -->
<g class="moth" transform="translate(150 67)">
<g class="moth__half">
<path class="moth__wing" d="M-2.2-3.6C-9-15.5-24.6-25.4-34.4-20.6-38.8-16.4-35.6-7.6-27.6-2.4-19.6 2-9.6 2.4-2.2 1.2Z"/>
<path class="moth__wing" d="M-2.2 1.6C-9.6 3.4-21.6 8.6-22.6 16.4-22 22.4-13 23-7.6 17.2-4.2 13.4-2.4 7.8-2.2 3.4Z"/>
<path class="moth__vein" d="M-30.4-15.6C-23.6-12.6-15.4-8.4-5.4-2.2M-27.6-6C-20.2-5-13.4-2.8-5.6-.4M-17.4-19.6C-15-13.4-11.6-7.6-6.2-2.8"/>
<path class="moth__eye" d="M-22.4-9.6a4.2 4.2 0 1 1 6.4 3.4"/>
<path class="moth__vein" d="M-18.4 15.4C-14 11.6-9.6 7.6-4.8 4.2M-13.6 19.4C-10.6 14.4-7.6 9.4-4.6 5.4"/>
<path class="moth__feeler" d="M-1.2-13.4C-4.6-20-9.4-24-15.2-25.6M-6.6-21.4-8.6-19.4M-9.4-23.2-11 -21M-12.4-24.6-13.4-22.2"/>
</g>
<g class="moth__half" transform="scale(-1 1)">
<path class="moth__wing" d="M-2.2-3.6C-9-15.5-24.6-25.4-34.4-20.6-38.8-16.4-35.6-7.6-27.6-2.4-19.6 2-9.6 2.4-2.2 1.2Z"/>
<path class="moth__wing" d="M-2.2 1.6C-9.6 3.4-21.6 8.6-22.6 16.4-22 22.4-13 23-7.6 17.2-4.2 13.4-2.4 7.8-2.2 3.4Z"/>
<path class="moth__vein" d="M-30.4-15.6C-23.6-12.6-15.4-8.4-5.4-2.2M-27.6-6C-20.2-5-13.4-2.8-5.6-.4M-17.4-19.6C-15-13.4-11.6-7.6-6.2-2.8"/>
<path class="moth__eye" d="M-22.4-9.6a4.2 4.2 0 1 1 6.4 3.4"/>
<path class="moth__vein" d="M-18.4 15.4C-14 11.6-9.6 7.6-4.8 4.2M-13.6 19.4C-10.6 14.4-7.6 9.4-4.6 5.4"/>
<path class="moth__feeler" d="M-1.2-13.4C-4.6-20-9.4-24-15.2-25.6M-6.6-21.4-8.6-19.4M-9.4-23.2-11 -21M-12.4-24.6-13.4-22.2"/>
</g>
<path class="moth__body" d="M0-13.2C1.9-13.2 2.9-10.4 2.9-6.4 2.9 1 2.4 9.6 0 15.4-2.4 9.6-2.9 1-2.9-6.4-2.9-10.4-1.9-13.2 0-13.2Z"/>
<path class="moth__rings" d="M-2.4-1.4H2.4M-2.3 2.6H2.3M-2 6.6H2M-1.5 10.4H1.5"/>
</g>
</svg>
<span class="label__brand">Mothlight</span>
<span class="label__word" data-word><span class="label__w">Menu</span></span>
<span class="label__line">Candle Co. · Hand-poured</span>
</span>
<!-- the bottom side, just showing -->
<span class="sleeve__edge" aria-hidden="true"></span>
<span class="sr">Menu</span>
</button>
<!-- the sleeve's shadow on the tray where it comes out -->
<span class="mb__shade" aria-hidden="true" data-shade></span>
<!-- the tray and the matches: each match is a section of the site -->
<div class="tray" data-tray>
<span class="tray__floor" aria-hidden="true"></span>
<ul class="tray__list" id="mb-sections" aria-label="Sections" data-list>
<li class="slot" data-slot>
<a class="match" href="#candles" aria-current="page" data-match>
<span class="match__art"><span class="match__stick"></span><span class="match__name">Candles</span><span class="match__head"><i class="match__sear"></i></span></span>
</a>
</li>
<li class="slot" data-slot>
<a class="match" href="#gift-sets" data-match>
<span class="match__art"><span class="match__stick"></span><span class="match__name">Gift sets</span><span class="match__head"><i class="match__sear"></i></span></span>
</a>
</li>
<li class="slot" data-slot>
<a class="match" href="#workshops" data-match data-autoclic>
<span class="match__art"><span class="match__stick"></span><span class="match__name">Workshops</span><span class="match__head"><i class="match__sear"></i></span></span>
</a>
</li>
<li class="slot" data-slot>
<a class="match" href="#our-story" data-match>
<span class="match__art"><span class="match__stick"></span><span class="match__name">Our story</span><span class="match__head"><i class="match__sear"></i></span></span>
</a>
</li>
<li class="slot" data-slot>
<a class="match" href="#stockists" data-match data-autoclic>
<span class="match__art"><span class="match__stick"></span><span class="match__name">Stockists</span><span class="match__head"><i class="match__sear"></i></span></span>
</a>
</li>
</ul>
<span class="tray__rim" aria-hidden="true"></span>
</div>
<!-- the hand: the match that is taken out flies here -->
<div class="hand" aria-hidden="true" data-hand></div>
</div>
<!-- the light of the flame on the box (a light map, in multiply), then the flame, the sparks, the smoke and the
glow (added in screen) -->
<canvas class="fx fx--shade" aria-hidden="true" data-fx-shade></canvas>
<canvas class="fx" aria-hidden="true" data-fx></canvas>
</nav>
<p class="sr" aria-live="polite" data-say></p>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="script.js"></script>
</body>
</html>Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.
32 values to change
With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:
Take “The matchbox menu” from Animorio and fit it into my siteEverything in the box is drawn in its own pixels (an SVG label with the grain of the paper and of the striker made by feTurbulence, the tray, the matches in CSS) and the script scales the whole box: closed it is 60 pixels high on every screen, open it is the size of the drawing. Opening, a GSAP timeline brings it towards you with a back.out and a two-degree turn that straightens, as if picked up.
The thumb pushes the tray through a stiff spring (the skin and the card giving). Until the push beats static friction the tray stays put; then it slides with the lower kinetic friction, overtakes the thumb a little, slows down and grips again: stick-slip, in steps of 1/240 of a second with the frame capped at 1/30. At the lip the tray bounces back with BOUNCE of its speed, and the matches, lying loose, feel its acceleration the other way: they slide and turn a little in their lanes and come back on a spring.
The chosen match is copied into a ‘hand’ layer above the box, in CSS 3D: the fingers are the pivot, rotateZ turns it, rotateY lifts its head towards you, and the head always faces the viewer (a rotation the other way). The hand draws it out along its own lane, tilted almost towards you, until its end has passed the longest name in the lanes above (measured once the typeface has arrived); only then does it rise, over the heads. While its end is still inside the box, a clip-path with a hole the size of the label hides it under the card.
On the canvas the flame is a drop (x = sin t · sin^m(t/2), y = cos t) drawn in four layers added in lighter mode: an edgeless glow, the body from blue to orange, the white heart, the blue cup at the root. Height and width tremble on a smooth noise, the tip sways more and bends on a spring against the head’s speed. The sparks are streaks that fall and cool from white to red, the sulphur is puffs that spread, the smoke is a row of points that rise on a noise field shared with their neighbours and are joined into a double curve, haze and thread.
Two canvases above the box, clipped to its shape: one in multiply is a light map that sends the box into a warm dusk away from the flame and keeps it bright and golden near it; the other in screen adds the glow. So the light falls on the label and the nearby matches and the black of the page stays black. When nothing burns, smokes or sparks, the canvases are cleared once and not drawn again.
The box is a button with aria-expanded and aria-controls, the matches are links in a list with aria-current on the burnt one, inert while the box is closed. Arrows, Home, End, Enter, Escape which closes and gives the focus back; the focus shows only from the keyboard. A choice while a match burns shortens the flame and speeds the hand up, closing waits until the match is back. The preview’s click on a match while the box is closed opens it and lights the match when the tray knocks against the lip.
| Name | File | Default | What it changes |
|---|---|---|---|
FRICTION | script.js | 1 | How much the tray sticks and slips as it slides (0 it glides, 1.6 it judders). |
PUSH | script.js | 260 | How fast the thumb pushes the tray, in pixels per second. |
BOUNCE | script.js | 0.35 | How much of its speed the tray keeps as it bounces off the lip (0 none, 0.7 lively). |
PACE | script.js | 1 | The pace of the hand that takes a match out, strikes it and lays it back. |
BURN | script.js | 1.2 | The seconds a match burns before the hand shakes it out. |
FLAME | script.js | [52, 1] | The flame: its height in pixels, and how much it flickers (0 still, 2 restless). |
SPARKS | script.js | 18 | The sparks a strike throws. |
GLOW | script.js | 0.6 | How much light the flame throws on the box (0 none, 1 a lot). |
SPENT | script.js | 'current' | Which matches stay burnt: 'current' only the one of the section you are on, 'visited' every match struck. |
BY_HAND | script.js | true | A match dragged fast across the striker lights itself. |
OPEN_WORD | script.js | 'Close' | What the label says while the box is open. |
For the menu of a site with an object-like character and some warmth: a candle maker, a cocktail bar, a tobacconist, a restaurant, a distillery, a homeware shop. From four to six sections with short names; the burning match is also the moment while the new page loads.
The closed box stays 60 pixels high; open, it shrinks to the width of the screen (at 390 pixels it is about nine tenths) and nothing runs off the side. A finger pressing a match lifts and rolls it like the pointer, and letting go chooses it; dragging it, it can be struck on the strip.
No travel and no bouncing: the box fades out closed and comes back open. Choosing a match, it does not move: a still flame appears on its head with its light on the box, the name warms up, then the flame fades away and the match turns burnt. Under the pointer the match brightens instead of lifting.
GSAP 3.15.0, Fraunces (font variabile) 5.3.0, Sofia Sans Condensed (font variabile) 5.3.0.
Compressed with gzip, the three demo files weigh 21 KB; the libraries: GSAP 28 KB.
Tested on 7 October 2026
Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.
With your account’s email and password.
or with your email
You’re signed in as .
With your account, you can download the zip of every animation and every pack, for free.