Skip to content
animorio
IT
All components

The grind picker

Pickers, Physics, SVG, TransitionsCanvas, GSAPAdvancedCompressed weight: Demo 20 KBGSAP 28 KBFreeDownload the zip

The grind of a bag of coffee in a roaster's cart, from Espresso to Whole beans, beside a small grinder that fills a glass jar: choosing one, the jar's floor opens and the coffee drops into the stand, the ring turns, the burrs spin and the new grind falls from the chute and heaps up at its own angle. The grains have real granular physics, and their size is the grind.

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 grind picker</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/bricolage-grotesque@5.3.0/index.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/instrument-sans@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the grind picker of a coffee roaster's cart: five real radio buttons, from Espresso to Whole beans, beside a
         small burr grinder that fills a glass jar. Choosing a grind, the jar's floor opens and the coffee in it drops
         into the stand, the grind ring turns to the new setting, the burrs spin and the new coffee falls from the chute
         and heaps up in the jar. The grains are a canvas with real granular physics, and their size is the grind:
         a fine powder for espresso, coarse chips for French press, whole beans with their crease. The grinder and the
         glass are SVG, behind and in front of the grains -->
    <div class="grind" data-grind>
      <div class="grind__machine" aria-hidden="true" data-machine>
        <svg class="grind__art" viewBox="0 0 256 340" data-art="back">
          <defs>
            <linearGradient id="gm-body" x1="0" y1="0" x2="1" y2="0">
              <stop class="s-body-light" offset="0"/>
              <stop class="s-body" offset="0.32"/>
              <stop class="s-body" offset="0.62"/>
              <stop class="s-body-dark" offset="1"/>
            </linearGradient>
            <linearGradient id="gm-steel" x1="0" y1="0" x2="1" y2="0">
              <stop class="s-steel-dark" offset="0"/>
              <stop class="s-steel-light" offset="0.24"/>
              <stop class="s-steel" offset="0.55"/>
              <stop class="s-steel-dark" offset="0.86"/>
              <stop class="s-steel" offset="1"/>
            </linearGradient>
            <linearGradient id="gm-steel-v" x1="0" y1="0" x2="0" y2="1">
              <stop class="s-steel-light" offset="0"/>
              <stop class="s-steel" offset="0.45"/>
              <stop class="s-steel-dark" offset="1"/>
            </linearGradient>
            <linearGradient id="gm-cone" x1="0" y1="0" x2="1" y2="0">
              <stop class="s-steel-dark" offset="0"/>
              <stop class="s-steel-light" offset="0.34"/>
              <stop class="s-steel" offset="0.62"/>
              <stop class="s-steel-deep" offset="1"/>
            </linearGradient>
            <linearGradient id="gm-copper" x1="0" y1="0" x2="0" y2="1">
              <stop class="s-copper-light" offset="0"/>
              <stop class="s-copper" offset="0.6"/>
              <stop class="s-copper-dark" offset="1"/>
            </linearGradient>
            <linearGradient id="gm-glass" x1="0" y1="0" x2="1" y2="0">
              <stop class="s-glass" offset="0" stop-opacity="0.9"/>
              <stop class="s-glass" offset="0.2" stop-opacity="0.35"/>
              <stop class="s-glass" offset="0.8" stop-opacity="0.35"/>
              <stop class="s-glass" offset="1" stop-opacity="0.9"/>
            </linearGradient>
            <linearGradient id="gm-wall" x1="0" y1="0" x2="1" y2="0">
              <stop class="s-glass-edge" offset="0" stop-opacity="0.75"/>
              <stop class="s-glass" offset="0.6" stop-opacity="0.5"/>
              <stop class="s-glint" offset="1" stop-opacity="0.35"/>
            </linearGradient>
            <linearGradient id="gm-wall-r" x1="1" y1="0" x2="0" y2="0">
              <stop class="s-glass-edge" offset="0" stop-opacity="0.75"/>
              <stop class="s-glass" offset="0.6" stop-opacity="0.5"/>
              <stop class="s-glint" offset="1" stop-opacity="0.3"/>
            </linearGradient>
            <linearGradient id="gm-glint" x1="0" y1="0" x2="0" y2="1">
              <stop class="s-glint" offset="0" stop-opacity="0"/>
              <stop class="s-glint" offset="0.18" stop-opacity="0.75"/>
              <stop class="s-glint" offset="0.7" stop-opacity="0.45"/>
              <stop class="s-glint" offset="1" stop-opacity="0"/>
            </linearGradient>
            <linearGradient id="gm-shade" x1="0" y1="0" x2="1" y2="0">
              <stop offset="0" stop-color="#000" stop-opacity="0.5"/>
              <stop offset="0.3" stop-color="#000" stop-opacity="0"/>
              <stop offset="0.72" stop-color="#000" stop-opacity="0"/>
              <stop offset="1" stop-color="#000" stop-opacity="0.55"/>
            </linearGradient>
            <filter id="gm-soft" x="-20%" y="-200%" width="140%" height="500%"><feGaussianBlur stdDeviation="3.2"/></filter>
            <clipPath id="gm-cone-clip"><path d="M52 131H68L80 161H40Z"/></clipPath>
            <clipPath id="gm-collar-clip"><rect x="22" y="100" width="76" height="16" rx="3"/></clipPath>
          </defs>

          <!-- the shadow on the page -->
          <ellipse class="m-shadow" cx="128" cy="331" rx="120" ry="4.5" filter="url(#gm-soft)"/>

          <g data-shake>
            <!-- the hopper: its lid, the back of its glass -->
            <rect x="16" y="10" width="88" height="13" rx="5" fill="url(#gm-body)"/>
            <rect class="m-edge-light" x="18" y="11" width="84" height="1.2" rx="0.6"/>
            <rect x="52" y="3.5" width="16" height="8" rx="3" fill="url(#gm-copper)"/>
            <path class="m-glass-back" d="M19 23H101L83 95Q81.5 100 77 100H43Q38.5 100 37 95Z"/>

            <!-- the grind ring: brushed steel, its marks drawn by the script as it turns -->
            <rect x="22" y="100" width="76" height="16" rx="3" fill="url(#gm-steel)"/>
            <g clip-path="url(#gm-collar-clip)" data-ticks></g>
            <rect class="m-ring-shade" x="22" y="100" width="76" height="16" rx="3" fill="url(#gm-shade)"/>

            <!-- the body -->
            <path d="M25 116H95V306H25Z" fill="url(#gm-body)"/>
            <rect class="m-edge-light" x="29" y="116" width="1.4" height="182" rx="0.7"/>
            <path class="m-seam" d="M25 176.5H95"/>
            <path class="m-pointer" d="M56 122.5L60 117.5L64 122.5Z"/>

            <!-- the window onto the burrs: the inner cone with its teeth, the outer ring cut in half -->
            <rect class="m-window" x="36" y="127" width="48" height="36" rx="7"/>
            <path d="M52 131H68L80 161H40Z" fill="url(#gm-cone)"/>
            <g clip-path="url(#gm-cone-clip)"><g class="m-teeth" data-teeth></g></g>
            <path d="M52 131H68L80 161H40Z" fill="url(#gm-shade)" opacity="0.8"/>
            <path class="m-burr-ring" d="M38 129.5H47L41.5 161H38Z M82 129.5H73L78.5 161H82Z"/>
            <path class="m-burr-teeth" d="M47 133L44.5 134.6M46.2 138L43.6 139.6M45.3 143L42.7 144.6M44.4 148L41.8 149.6M43.5 153L40.9 154.6M73 133L75.5 134.6M73.8 138L76.4 139.6M74.7 143L77.3 144.6M75.6 148L78.2 149.6M76.5 153L79.1 154.6"/>
            <path class="m-window-glint" d="M41 158L52 130H57L46 158Z"/>
            <rect class="m-window-rim" x="36" y="127" width="48" height="36" rx="7"/>

            <!-- the switch, low on the body -->
            <rect class="m-switch" x="52" y="236" width="16" height="24" rx="4"/>
            <rect class="m-switch-top" x="54" y="238" width="12" height="9" rx="2.5"/>

            <!-- the foot -->
            <rect x="18" y="298" width="84" height="19" rx="5" fill="url(#gm-body)"/>
            <rect class="m-edge-light" x="21" y="298.6" width="78" height="1.1" rx="0.55"/>
          </g>

          <!-- the jar's glass behind the coffee, and the far half of its rim -->
          <rect x="120" y="206" width="116" height="92" fill="url(#gm-glass)"/>
          <path class="m-rim-back" d="M120.5 206.5C120.5 200.5 235.5 200.5 235.5 206.5"/>
        </svg>

        <canvas class="grind__grounds" data-canvas></canvas>

        <svg class="grind__art" viewBox="0 0 256 340" data-art="front">
          <!-- the hopper's glass, in front of the beans -->
          <g data-shake>
            <path class="m-glass-front" d="M19 23H101L83 95Q81.5 100 77 100H43Q38.5 100 37 95Z"/>
            <path class="m-glint-line" d="M27 28L44.5 90"/>
            <path class="m-glint-line is-thin" d="M93 28L80.5 78"/>

            <!-- the chute, out of the burrs and down into the jar -->
            <path d="M94 135H120C146 135 163.8 165.2 171.3 185.9L148.7 194.1C143.2 179.1 135 165 116 165H94Z" fill="url(#gm-steel-v)"/>
            <path class="m-chute-lip" d="M94 135.6H120C146 135.6 163.6 165.6 170.9 185.6"/>
            <path class="m-chute-mouth" d="M171.3 185.9L148.7 194.1"/>
            <rect x="91.5" y="132" width="6" height="36" rx="2.5" fill="url(#gm-body)"/>
          </g>

          <!-- the jar: its walls seen edge-on, the light on the glass and the near half of the rim -->
          <g data-jar>
            <rect x="120" y="206" width="5" height="90" fill="url(#gm-wall)"/>
            <rect x="231" y="206" width="5" height="90" fill="url(#gm-wall-r)"/>
            <path class="m-glass-line" d="M120.5 206V296M235.5 206V296"/>
            <rect x="131" y="214" width="6" height="80" rx="3" fill="url(#gm-glint)"/>
            <rect x="140" y="222" width="1.6" height="58" rx="0.8" fill="url(#gm-glint)" opacity="0.6"/>
            <rect x="223" y="216" width="2.4" height="72" rx="1.2" fill="url(#gm-glint)" opacity="0.7"/>
            <path class="m-rim-front" d="M120.5 206.5C120.5 212.5 235.5 212.5 235.5 206.5"/>
            <path class="m-rim-glint" d="M128 209.6C150 212 206 212 228 209.6"/>
          </g>

          <!-- the stand the jar sits in: its floor opens into it. The little lever on its side moves with the gate -->
          <g data-lever>
            <path class="m-lever" d="M238 303.2H249"/>
            <rect x="246.5" y="300.2" width="8" height="6" rx="3" fill="url(#gm-copper)"/>
          </g>
          <rect x="116" y="294" width="124" height="23" rx="3" fill="url(#gm-steel-v)"/>
          <rect x="116" y="294" width="124" height="23" rx="3" fill="url(#gm-shade)" opacity="0.5"/>
          <path class="m-stand-lip" d="M118 296.2H238"/>
          <circle class="m-pivot" cx="238.5" cy="303.2" r="2.2"/>

          <!-- the base under it all -->
          <rect x="6" y="316" width="244" height="14" rx="5" fill="url(#gm-body)"/>
          <rect class="m-edge-light" x="9" y="316.6" width="238" height="1.1" rx="0.55"/>
        </svg>
      </div>

      <div class="grind__pick">
        <div class="grind__head">
          <span class="grind__title" id="grind-title">Grind</span>
          <span class="grind__hint">Ground the day we ship</span>
        </div>
        <div class="grind__list" role="radiogroup" aria-labelledby="grind-title" data-list>
          <span class="grind__mark" aria-hidden="true" data-mark></span>
          <label class="opt" data-opt>
            <input class="opt__radio" type="radio" name="grind" value="espresso">
            <span class="opt__name">Espresso</span>
            <span class="opt__line">Fine as table salt</span>
            <span class="opt__size">250 µm</span>
          </label>
          <label class="opt" data-opt>
            <input class="opt__radio" type="radio" name="grind" value="moka">
            <span class="opt__name">Moka</span>
            <span class="opt__line">A touch coarser, like fine sand</span>
            <span class="opt__size">450 µm</span>
          </label>
          <label class="opt" data-opt>
            <input class="opt__radio" type="radio" name="grind" value="filter">
            <span class="opt__name">Filter</span>
            <span class="opt__line">Medium, like beach sand</span>
            <span class="opt__size">700 µm</span>
          </label>
          <label class="opt" data-opt>
            <input class="opt__radio" type="radio" name="grind" value="french-press">
            <span class="opt__name">French press</span>
            <span class="opt__line">Coarse as sea salt</span>
            <span class="opt__size">1 mm</span>
          </label>
          <label class="opt" data-opt>
            <input class="opt__radio" type="radio" name="grind" value="whole-beans">
            <span class="opt__name">Whole beans</span>
            <span class="opt__line">Grind them just before you brew</span>
            <span class="opt__size">Whole</span>
          </label>
        </div>
      </div>
    </div>
  </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.

58 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 grind picker” from Animorio and fit it into my site

What it does

On a white background, on the left a matte black burr grinder on a dark base: on top a glass hopper full of beans, below it the steel grind ring with its marks and a small copper pointer, a little window onto the conical burr, and a curved steel chute coming down over a glass jar that stands in a holder with a small copper lever. In the jar there is a heap of ground coffee. On the right the title Grind, the line Ground the day we ship and five options, each with its name, a line about the grain and its size: Espresso (Fine as table salt, 250 µm), Moka, Filter, French press and Whole beans. The one chosen sits on a cream card with a dark edge. Choosing another, the card slides to it with a little bounce; in the jar the two halves of the floor swing down and the coffee drops into the stand, the middle first and then the sides, while the lever goes down; the ring turns to the new grind's mark, the burrs spin in the window, the grinder hums a little and the beans in the hopper shiver. The floor closes and the new coffee falls from the chute: a rain of fine powder for espresso, grains for moka and filter, sharp chips for French press, whole beans with their crease that bounce and roll. The grind heaps up under the stream and slides down its sides until the heap stands still at its angle, steeper the finer it is.

How it works

A grinder in two layers

The grinder and the jar are hand-drawn SVG, in two layers: behind the canvas the body, the hopper, the ring and the far glass of the jar; in front the hopper's glass, the chute, the jar's sides and reflections, the holder and the base. So the coffee passes behind the chute, inside the glass, and vanishes into the holder. The marks of the ring are a cylinder seen from the front: each sits at x = centre + radius · sin(angle) and grows paler with cos(angle), and the ring turns on a GSAP spring to the mark of the grind chosen. The burr's teeth slide past the window while the motor runs.

Grains on positions

Each grain is a circle, and the world moves on in steps of 1/240 of a second (a slow frame counts for 1/30 at most): gravity moves the grains, then two passes push them apart where they overlap and out of the glass, and the speed is the move divided by the step. Friction works on positions: a slide shorter than the grip (GRIP) times the overlap is undone, a longer one is shortened. Before every step what still overlaps is pulled apart without giving any speed, the grains lower down weigh more, and the neighbours are found with a grid sorted by counting.

The grain is the grind

The grains' radius comes from SIZES, with some finer dust in it, as in any real grind; their number comes from DOSE, so the volume of coffee is always the same: almost 1500 grains for espresso, under 140 for French press, 35 whole beans. The air holds the small grains back more (the drag is divided by the radius), so the powder drifts down while the chips drop straight. A whole bean is two circles at a fixed distance that bounce a little when they hit: so the bean lies flat, turns and rolls.

The floor that opens and the heap that stops

The jar's floor is two plates that turn down round hinges on the walls: the coffee drops the middle first and then the sides, and what passes under the base is gone. When the jar is empty the floor closes and the chute pours the dose in rows of grains side by side, never overlapping, in POUR seconds. A grain that stays within half its radius for a moment falls asleep and counts as still; one covered by others falls asleep sooner, so only the skin of the heap moves, as in a real heap. When everything sleeps nothing is drawn any more.

Painted once

Each grind is a dozen little images painted once for the screen's density: chips with few corners, a paler face where the bean was cut and a dark edge; the beans have the light and shade of the roast, the crease and the pale silverskin left in it. Each grain lays down its image turned with it. Behind the grains at rest there is a darker round, so the gaps between them look like more coffee too; the sleeping grains sit in a picture of their own, redrawn only when they change, and a shade over it all darkens the coffee towards the jar's sides, since the jar is round.

Values to change

NameFileDefaultWhat it changes
STARTscript.js2The grind chosen when the page opens (0 is Espresso, 4 Whole beans).
SIZESscript.js[0.85, 1.3, 1.9, 2.8]How big a grain is for each grind, finest first: its radius, in pixels of the drawing.
BEANscript.js[12.4, 8.6]A whole bean, long and wide, in pixels of the drawing.
DOSEscript.js0.38How much of the jar the coffee fills, as a share of it.
POURscript.js1.35The seconds the grinder takes to fill the jar.
GRAVITYscript.js1700How fast the coffee falls, in pixels per second squared.
GRIPscript.js[1.15, 0.9, 0.6, 0.44, 0.3]How much the grains hold on to each other, for each grind: more, a steeper heap.
SPINscript.js1How fast the burrs turn in their window.

Try them in the Playground

When to use it

On the page of a bag of coffee or in a roaster's cart, to choose the grind; it fits any choice where the difference between the options is a grain or a size that can be shown: salt and sugar, flours, loose tea, gravel for a fish tank, garden sand.

On phones

Under 640 pixels the grinder sits above the options, 212 pixels wide, and the options take the whole width: nothing spills over. The physics is the same, with the same grains: only the size the canvas draws them at changes. A tap on an option is a click at once, with no wait for a double tap.

With reduced motion

No coffee falling: the new heap is made at once, already still, and fades in over the old one; the ring jumps to its mark, the burrs do not turn and the grinder does not hum. The cream card fades out and back in on the new option instead of sliding, and when the page opens everything fades in.

Pitfalls

  • The first grains shot off in every direction as soon as they left the chute: they were born overlapping, and the position correction, divided by a step of 1/240 of a second, became a huge speed. Now they come out in rows of grains side by side, as far apart as the biggest grain, and each row is born already as far along as it would have had time to go.
  • With the glass's friction holding the grains, a column of coffee stayed stuck to the wall: the glass now only slows them, it never holds them.
  • The heap never fell asleep: the grains at the bottom shivered at a dozen pixels a second while staying put. Now a grain falls asleep if for a moment it stays within half its radius, whatever its speed; and before every step what still overlaps is pulled apart without giving any speed.
  • While emptying, some grains fell asleep resting on others about to fall, and hung in mid-air keeping the floor open until the safety limit: while the floor is open nobody sleeps.
  • A heap of espresso, a fine cone while the stream fed it, melted flat in three tenths of a second: with positions the load of the coffee above never reaches the grains below, which slide as on ice, and neither more friction nor more passes were enough. Now a slow grain covered by others falls asleep up to three times sooner, the finer the sooner: only the skin of the heap moves, as in a real heap.
  • The whole beans piled up in balanced columns: landing in the same spot they settled on each other and fell asleep just as they began to topple. Now they bounce a little when they hit, leave the chute a little scattered and wait half a second before sleeping.
  • The espresso powder reached the heap too fast and dug into it: the air holds each grain back in inverse proportion to its radius, so the powder drifts down while the chips drop straight.
  • While the cream card slid, its dark edge ran across the lines of text like a strikethrough: now only the cream card travels, behind the words, and the edge comes back on arrival.
  • Drawing 1500 grains on every frame cost too much: the sleeping ones sit in a picture of their own, redrawn only when one of them wakes or when enough have fallen asleep.

Libraries

GSAP 3.15.0, Bricolage Grotesque (font variabile) 5.3.0, Instrument Sans (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 20 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