
The cookie banner
Consent, Transitions, SVG, Physics
NewFree
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.
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>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
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 siteThe 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.
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 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 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.
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.
| Name | File | Default | What it changes |
|---|---|---|---|
START | script.js | 2 | The grind chosen when the page opens (0 is Espresso, 4 Whole beans). |
SIZES | script.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. |
BEAN | script.js | [12.4, 8.6] | A whole bean, long and wide, in pixels of the drawing. |
DOSE | script.js | 0.38 | How much of the jar the coffee fills, as a share of it. |
POUR | script.js | 1.35 | The seconds the grinder takes to fill the jar. |
GRAVITY | script.js | 1700 | How fast the coffee falls, in pixels per second squared. |
GRIP | script.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. |
SPIN | script.js | 1 | How fast the burrs turn in their window. |
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.
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.
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.
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.
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.