Skip to content
animorio
IT
All components

The matchbox menu

Menus, Physics, 3D, TransitionsCanvas, GSAP, CSS onlyAdvancedCompressed weight: Demo 21 KBGSAP 28 KBFreeDownload the zip

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.

The code

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. &middot; 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>

Playground

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

Or ask Claude Code for it

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 site

What it does

On a black background there is only a small box of matches, 60 pixels high: a cream label printed in brick red with a moth, the name Mothlight and the word Menu in a soft italic, the brown rough strip along its top side and the edge of the bottom one. Moving over it, the tray comes out a few millimetres and the red heads peep out. Pressed, the box is lifted towards you, a little crooked, and straightens; the word rolls from Menu to Close, and a thumb pushes the tray out: the card grips and slips in jerks, the tray knocks against the lip, bounces a touch, and the matches inside jump. Inside lie five wooden matches with red heads, one per section of the site, the name printed along the stick: Candles, Gift sets, Workshops, Our story, Stockists. The one for the page you are on is already burnt, with a charcoal head, a blackened tip and its name the colour of embers. Moving back over the open box, the tray slides in a touch, as if about to close. Moving over a match, it lifts, rolls a little on itself (the name turns with it) and a light runs along the wood. Choosing it, the hand lifts its head towards you and draws it out along its own lane, so it never passes over another name; it carries it over the box, sets the head on the rough strip and strikes: sparks fly, the head catches in a flash and a puff of sulphur, and the hand lifts it, tilted, above the tray. The flame, a drop that is blue at the root, white in its heart and orange at the tip, flickers, bends when the match moves and throws a warm light on the box: the label and the nearby matches warm up, the rest falls into dusk, the name on the stick lights up orange and the wood blackens back from the head. After a moment the hand shakes it, the flame goes out, the head stays red for an instant and a thread of smoke rises and curls; the match goes back to its lane burnt, and the burnt one from before is new again. It can be lit by hand too: dragging a match and rubbing its head along the strip fast enough, it catches by itself. From the keyboard the box opens with Enter, Space or the arrows, the arrows go from match to match, Enter lights one, Escape closes and gives the focus back to the box.

How it works

The box that is lifted

Everything 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.

Real friction: stick and slip

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.

A match taken out without covering any name

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.

The flame, every frame

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.

The light on the box

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.

A real menu

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.

Values to change

NameFileDefaultWhat it changes
FRICTIONscript.js1How much the tray sticks and slips as it slides (0 it glides, 1.6 it judders).
PUSHscript.js260How fast the thumb pushes the tray, in pixels per second.
BOUNCEscript.js0.35How much of its speed the tray keeps as it bounces off the lip (0 none, 0.7 lively).
PACEscript.js1The pace of the hand that takes a match out, strikes it and lays it back.
BURNscript.js1.2The seconds a match burns before the hand shakes it out.
FLAMEscript.js[52, 1]The flame: its height in pixels, and how much it flickers (0 still, 2 restless).
SPARKSscript.js18The sparks a strike throws.
GLOWscript.js0.6How much light the flame throws on the box (0 none, 1 a lot).
SPENTscript.js'current'Which matches stay burnt: 'current' only the one of the section you are on, 'visited' every match struck.
BY_HANDscript.jstrueA match dragged fast across the striker lights itself.
OPEN_WORDscript.js'Close'What the label says while the box is open.

Try them in the Playground

When to use it

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.

On phones

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.

With reduced motion

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.

Pitfalls

  • The match in the hand lives in a layer above the box: just taken, its end, still under the label, showed on top of the moth. While the end is inside, the layer has a clip-path with a hole (a path with evenodd) the size of the label.
  • Carrying the match straight up passed it over the names of the lanes above. Now it leaves along its own lane, its head lifted towards the viewer so it fits in the width, until its end has passed the longest name above: the widths of the names are measured once the typeface has arrived.
  • The smoke followed the head of the next match, or the tray going back in, and drew straight lines like wire: the flame and the smoke now each have their own element to follow, too big a jump starts a new thread, no smoke is born while closing, and it only starts after the shake.
  • The outer layer of the flame, a filled drop, showed as a dark shell round the fire: it is now an edgeless ellipse of light.
  • A dark veil over the box to make the light stand out turned the label grey and dirty: in its place a light map in multiply, warm and bright near the flame, plus a glow in screen.
  • The flame’s halo was cut against the right edge of the canvas: the canvas now spills 48 pixels past each side, and the page clips it with overflow-x: clip without scrolling sideways.
  • In the tray that peeps out under the pointer, 9 pixels of drawing became less than 4 on screen with the box closed and the heads did not show: now it peeps out by 24.
  • The catalogue preview’s click also lands on a match while the box is closed: the box opens and the match is lit when the tray reaches the lip, instead of nothing happening.

Libraries

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.

Testing

Tested on 7 October 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: To do

License

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.

All components