Grind
import { useState, type CSSProperties } from 'react';
const STOPS = [
{ at: 0, grind: 'Coarse', brew: 'French press' },
{ at: 1 / 3, grind: 'Medium', brew: 'Pour-over' },
{ at: 2 / 3, grind: 'Fine', brew: 'Espresso' },
{ at: 1, grind: 'Powder', brew: 'Turkish' },
];
const stop = (v: number) => STOPS[Math.round(v * 3)];
const css = `
.slider-grind { --wood: light-dark(#6d7179, #393c41); --metal: light-dark(#cbced3, #a7abb2); display: grid; gap: 12px; width: 230px; }
.slider-grind__bench { position: relative; height: 122px; }
/* A box grinder: wooden body with a drawer, a steel cup and a crank that turns as you drag. */
.slider-grind__box { position: absolute; left: 6px; bottom: 0; width: 66px; height: 54px; border-radius: 4px; background: repeating-linear-gradient(90deg, transparent 0 9px, color-mix(in oklab, light-dark(#fbfcfd, #121417) 12%, transparent) 9px 10px), linear-gradient(var(--wood), color-mix(in oklab, var(--wood) 80%, light-dark(#fbfcfd, #121417))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 25%, transparent), 0 10px 14px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
.slider-grind__drawer { position: absolute; left: 10px; right: 10px; bottom: 9px; height: 18px; border-radius: 2px; box-shadow: inset 0 0 0 1.5px color-mix(in oklab, light-dark(#fbfcfd, #121417) 30%, transparent); }
.slider-grind__drawer::after { content: ''; position: absolute; left: 50%; top: 50%; width: 7px; height: 7px; translate: -50% -50%; border-radius: 50%; background: var(--metal); }
.slider-grind__cup { position: absolute; left: 14px; bottom: 54px; width: 50px; height: 24px; background: linear-gradient(90deg, color-mix(in oklab, var(--metal) 70%, light-dark(#fbfcfd, #121417)), var(--metal) 40%, color-mix(in oklab, var(--metal) 60%, light-dark(#373a40, #f1f2f3)) 55%, color-mix(in oklab, var(--metal) 75%, light-dark(#fbfcfd, #121417))); clip-path: polygon(0 0, 100% 0, 84% 100%, 16% 100%); }
.slider-grind__shaft { position: absolute; left: 37px; bottom: 76px; width: 4px; height: 16px; background: var(--metal); }
/* The crank turns in a plane tipped toward you, so the arm swings round in perspective. */
.slider-grind__plane { position: absolute; left: 39px; bottom: 90px; width: 0; height: 0; transform-style: preserve-3d; transform: perspective(240px) rotateX(64deg); }
.slider-grind__arm { position: absolute; left: 0; top: -3px; width: 34px; height: 6px; border-radius: 3px; transform-origin: 0 50%; background: linear-gradient(var(--metal), color-mix(in oklab, var(--metal) 70%, light-dark(#fbfcfd, #121417))); rotate: calc(var(--slider-grind-v) * 1080deg); transition: rotate 0.45s cubic-bezier(0.3, 0.8, 0.3, 1); }
.slider-grind__arm::after { content: ''; position: absolute; right: -4px; top: -3px; width: 12px; height: 12px; border-radius: 50%; background: var(--wood); box-shadow: inset 0 0 0 2px color-mix(in oklab, light-dark(#fbfcfd, #121417) 25%, transparent); }
.slider-grind__arm::before { content: ''; position: absolute; left: -4px; top: -1px; width: 8px; height: 8px; border-radius: 50%; background: var(--metal); }
/* The loupe: the grounds magnified, coarse lumps shrinking to powder. */
.slider-grind__loupe { position: absolute; right: 4px; top: 2px; width: 116px; height: 116px; }
.slider-grind__lens { position: absolute; inset: 0; overflow: hidden; border-radius: 50%; background: light-dark(#5f626a, #232529); box-shadow: inset 0 0 0 6px var(--metal), inset 0 0 0 7px color-mix(in oklab, light-dark(#fbfcfd, #121417) 40%, transparent); }
/* Four layers of particles on tiles of unrelated sizes, turned off the grid, so no row lines up. */
.slider-grind__grounds { --g: calc(5px + (1 - var(--slider-grind-v)) * 15px); position: absolute; inset: -30%; rotate: 23deg; background: radial-gradient(circle at 30% 30%, light-dark(#cbced3, #7d8087) calc(var(--g) * 0.16), transparent calc(var(--g) * 0.16 + 0.6px)) 0 0 / calc(var(--g) * 1.13) calc(var(--g) * 0.97), radial-gradient(circle at 60% 40%, light-dark(#42454c, #1a1c1f) calc(var(--g) * 0.3), transparent calc(var(--g) * 0.3 + 0.6px)) 0 0 / var(--g) var(--g), radial-gradient(circle at 40% 60%, light-dark(#50535b, #2e3035) calc(var(--g) * 0.36), transparent calc(var(--g) * 0.36 + 0.6px)) calc(var(--g) * 0.4) calc(var(--g) * 0.7) / calc(var(--g) * 1.37) calc(var(--g) * 1.21), radial-gradient(circle at 50% 50%, light-dark(#6d7179, #393c41) calc(var(--g) * 0.24), transparent calc(var(--g) * 0.24 + 0.6px)) calc(var(--g) * 0.2) calc(var(--g) * 0.1) / calc(var(--g) * 0.83) calc(var(--g) * 1.61); }
.slider-grind__ring { position: absolute; inset: 0; border-radius: 50%; box-shadow: inset 0 0 0 6px var(--metal), inset 0 0 0 7px color-mix(in oklab, light-dark(#fbfcfd, #121417) 40%, transparent), inset 0 8px 16px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)); }
.slider-grind__lens::after { content: ''; position: absolute; inset: 6px; border-radius: 50%; background: linear-gradient(140deg, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 22%, transparent), transparent 40%); }
.slider-grind__handle { position: absolute; right: 2px; bottom: -14px; width: 12px; height: 36px; border-radius: 4px; rotate: -45deg; background: var(--wood); }
.slider-grind__read { display: flex; align-items: baseline; justify-content: space-between; }
.slider-grind__name { color: light-dark(#373a40, #f1f2f3); font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
.slider-grind__brew { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.slider-grind__track { position: relative; height: 22px; }
.slider-grind__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: linear-gradient(90deg, light-dark(#dbdde1, #2e3035), light-dark(#6d7179, #7d8087)); }
.slider-grind__dot { position: absolute; top: 8px; width: 6px; height: 6px; translate: -50% 0; border-radius: 50%; background: light-dark(#6d7179, #7d8087); }
/* The handle is a coffee bean. */
.slider-grind__thumb { position: absolute; top: 1px; left: calc(var(--slider-grind-v) * (100% - 26px)); width: 26px; height: 20px; border-radius: 50%; background: radial-gradient(ellipse at 40% 30%, color-mix(in oklab, var(--wood) 60%, light-dark(#373a40, #f1f2f3)), var(--wood) 70%); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); rotate: calc(var(--slider-grind-v) * 40deg - 20deg); }
.slider-grind__thumb::after { content: ''; position: absolute; left: 4px; right: 4px; top: 8px; height: 4px; border-radius: 50%; border-top: 1.5px solid color-mix(in oklab, light-dark(#fbfcfd, #121417) 45%, transparent); }
.slider-grind:has(.slider-grind__input:focus-visible) .slider-grind__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-grind__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-grind__input::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 22px; }
.slider-grind__input::-moz-range-thumb { width: 26px; height: 22px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-grind__arm { transition: none; }
}
`;
export function Preview() {
const [g, setG] = useState(66);
const v = g / 100;
const s = stop(v);
return (
<>
<style href="kit-sliders-grind" precedence="kit">
{css}
</style>
<div className="slider-grind" style={{ '--slider-grind-v': v } as CSSProperties}>
<div className="slider-grind__bench" aria-hidden="true">
<span className="slider-grind__box">
<span className="slider-grind__drawer" />
</span>
<span className="slider-grind__cup" />
<span className="slider-grind__shaft" />
<span className="slider-grind__plane">
<span className="slider-grind__arm" />
</span>
<span className="slider-grind__loupe">
<span className="slider-grind__handle" />
<span className="slider-grind__lens">
<span className="slider-grind__grounds" />
<span className="slider-grind__ring" />
</span>
</span>
</div>
<div className="slider-grind__read" aria-hidden="true">
<span className="slider-grind__name">{s.grind}</span>
<span className="slider-grind__brew">{s.brew}</span>
</div>
<div className="slider-grind__track">
<span className="slider-grind__rail" aria-hidden="true" />
{STOPS.map((t) => (
<span key={t.grind} className="slider-grind__dot" style={{ left: `calc(13px + ${t.at.toFixed(4)} * (100% - 26px))` }} aria-hidden="true" />
))}
<span className="slider-grind__thumb" aria-hidden="true" />
<input className="slider-grind__input" type="range" min={0} max={100} value={g} onChange={(e) => setG(+e.target.value)} aria-label="Grind size" aria-valuetext={`${s.grind}, for ${s.brew}`} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderGrind(el) on the mounted element instead.
<div class="slider-grind" style="--slider-grind-v:0.66">
<div class="slider-grind__bench" aria-hidden="true">
<span class="slider-grind__box">
<span class="slider-grind__drawer"></span>
</span>
<span class="slider-grind__cup"></span>
<span class="slider-grind__shaft"></span>
<span class="slider-grind__plane">
<span class="slider-grind__arm"></span>
</span>
<span class="slider-grind__loupe">
<span class="slider-grind__handle"></span>
<span class="slider-grind__lens">
<span class="slider-grind__grounds"></span>
<span class="slider-grind__ring"></span>
</span>
</span>
</div>
<div class="slider-grind__read" aria-hidden="true">
<span class="slider-grind__name">Fine</span>
<span class="slider-grind__brew">Espresso</span>
</div>
<div class="slider-grind__track">
<span class="slider-grind__rail" aria-hidden="true"></span>
<span class="slider-grind__dot" style="left:calc(13px + 0.0000 * (100% - 26px))" aria-hidden="true"></span>
<span class="slider-grind__dot" style="left:calc(13px + 0.3333 * (100% - 26px))" aria-hidden="true"></span>
<span class="slider-grind__dot" style="left:calc(13px + 0.6667 * (100% - 26px))" aria-hidden="true"></span>
<span class="slider-grind__dot" style="left:calc(13px + 1.0000 * (100% - 26px))" aria-hidden="true"></span>
<span class="slider-grind__thumb" aria-hidden="true"></span>
<input class="slider-grind__input" type="range" min="0" max="100" aria-label="Grind size" aria-valuetext="Fine, for Espresso" value="66"/>
</div>
</div>.slider-grind { --wood: light-dark(#6d7179, #393c41); --metal: light-dark(#cbced3, #a7abb2); display: grid; gap: 12px; width: 230px; }
.slider-grind__bench { position: relative; height: 122px; }
/* A box grinder: wooden body with a drawer, a steel cup and a crank that turns as you drag. */
.slider-grind__box { position: absolute; left: 6px; bottom: 0; width: 66px; height: 54px; border-radius: 4px; background: repeating-linear-gradient(90deg, transparent 0 9px, color-mix(in oklab, light-dark(#fbfcfd, #121417) 12%, transparent) 9px 10px), linear-gradient(var(--wood), color-mix(in oklab, var(--wood) 80%, light-dark(#fbfcfd, #121417))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 25%, transparent), 0 10px 14px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
.slider-grind__drawer { position: absolute; left: 10px; right: 10px; bottom: 9px; height: 18px; border-radius: 2px; box-shadow: inset 0 0 0 1.5px color-mix(in oklab, light-dark(#fbfcfd, #121417) 30%, transparent); }
.slider-grind__drawer::after { content: ''; position: absolute; left: 50%; top: 50%; width: 7px; height: 7px; translate: -50% -50%; border-radius: 50%; background: var(--metal); }
.slider-grind__cup { position: absolute; left: 14px; bottom: 54px; width: 50px; height: 24px; background: linear-gradient(90deg, color-mix(in oklab, var(--metal) 70%, light-dark(#fbfcfd, #121417)), var(--metal) 40%, color-mix(in oklab, var(--metal) 60%, light-dark(#373a40, #f1f2f3)) 55%, color-mix(in oklab, var(--metal) 75%, light-dark(#fbfcfd, #121417))); clip-path: polygon(0 0, 100% 0, 84% 100%, 16% 100%); }
.slider-grind__shaft { position: absolute; left: 37px; bottom: 76px; width: 4px; height: 16px; background: var(--metal); }
/* The crank turns in a plane tipped toward you, so the arm swings round in perspective. */
.slider-grind__plane { position: absolute; left: 39px; bottom: 90px; width: 0; height: 0; transform-style: preserve-3d; transform: perspective(240px) rotateX(64deg); }
.slider-grind__arm { position: absolute; left: 0; top: -3px; width: 34px; height: 6px; border-radius: 3px; transform-origin: 0 50%; background: linear-gradient(var(--metal), color-mix(in oklab, var(--metal) 70%, light-dark(#fbfcfd, #121417))); rotate: calc(var(--slider-grind-v) * 1080deg); transition: rotate 0.45s cubic-bezier(0.3, 0.8, 0.3, 1); }
.slider-grind__arm::after { content: ''; position: absolute; right: -4px; top: -3px; width: 12px; height: 12px; border-radius: 50%; background: var(--wood); box-shadow: inset 0 0 0 2px color-mix(in oklab, light-dark(#fbfcfd, #121417) 25%, transparent); }
.slider-grind__arm::before { content: ''; position: absolute; left: -4px; top: -1px; width: 8px; height: 8px; border-radius: 50%; background: var(--metal); }
/* The loupe: the grounds magnified, coarse lumps shrinking to powder. */
.slider-grind__loupe { position: absolute; right: 4px; top: 2px; width: 116px; height: 116px; }
.slider-grind__lens { position: absolute; inset: 0; overflow: hidden; border-radius: 50%; background: light-dark(#5f626a, #232529); box-shadow: inset 0 0 0 6px var(--metal), inset 0 0 0 7px color-mix(in oklab, light-dark(#fbfcfd, #121417) 40%, transparent); }
/* Four layers of particles on tiles of unrelated sizes, turned off the grid, so no row lines up. */
.slider-grind__grounds { --g: calc(5px + (1 - var(--slider-grind-v)) * 15px); position: absolute; inset: -30%; rotate: 23deg; background: radial-gradient(circle at 30% 30%, light-dark(#cbced3, #7d8087) calc(var(--g) * 0.16), transparent calc(var(--g) * 0.16 + 0.6px)) 0 0 / calc(var(--g) * 1.13) calc(var(--g) * 0.97), radial-gradient(circle at 60% 40%, light-dark(#42454c, #1a1c1f) calc(var(--g) * 0.3), transparent calc(var(--g) * 0.3 + 0.6px)) 0 0 / var(--g) var(--g), radial-gradient(circle at 40% 60%, light-dark(#50535b, #2e3035) calc(var(--g) * 0.36), transparent calc(var(--g) * 0.36 + 0.6px)) calc(var(--g) * 0.4) calc(var(--g) * 0.7) / calc(var(--g) * 1.37) calc(var(--g) * 1.21), radial-gradient(circle at 50% 50%, light-dark(#6d7179, #393c41) calc(var(--g) * 0.24), transparent calc(var(--g) * 0.24 + 0.6px)) calc(var(--g) * 0.2) calc(var(--g) * 0.1) / calc(var(--g) * 0.83) calc(var(--g) * 1.61); }
.slider-grind__ring { position: absolute; inset: 0; border-radius: 50%; box-shadow: inset 0 0 0 6px var(--metal), inset 0 0 0 7px color-mix(in oklab, light-dark(#fbfcfd, #121417) 40%, transparent), inset 0 8px 16px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)); }
.slider-grind__lens::after { content: ''; position: absolute; inset: 6px; border-radius: 50%; background: linear-gradient(140deg, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 22%, transparent), transparent 40%); }
.slider-grind__handle { position: absolute; right: 2px; bottom: -14px; width: 12px; height: 36px; border-radius: 4px; rotate: -45deg; background: var(--wood); }
.slider-grind__read { display: flex; align-items: baseline; justify-content: space-between; }
.slider-grind__name { color: light-dark(#373a40, #f1f2f3); font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
.slider-grind__brew { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.slider-grind__track { position: relative; height: 22px; }
.slider-grind__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: linear-gradient(90deg, light-dark(#dbdde1, #2e3035), light-dark(#6d7179, #7d8087)); }
.slider-grind__dot { position: absolute; top: 8px; width: 6px; height: 6px; translate: -50% 0; border-radius: 50%; background: light-dark(#6d7179, #7d8087); }
/* The handle is a coffee bean. */
.slider-grind__thumb { position: absolute; top: 1px; left: calc(var(--slider-grind-v) * (100% - 26px)); width: 26px; height: 20px; border-radius: 50%; background: radial-gradient(ellipse at 40% 30%, color-mix(in oklab, var(--wood) 60%, light-dark(#373a40, #f1f2f3)), var(--wood) 70%); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); rotate: calc(var(--slider-grind-v) * 40deg - 20deg); }
.slider-grind__thumb::after { content: ''; position: absolute; left: 4px; right: 4px; top: 8px; height: 4px; border-radius: 50%; border-top: 1.5px solid color-mix(in oklab, light-dark(#fbfcfd, #121417) 45%, transparent); }
.slider-grind:has(.slider-grind__input:focus-visible) .slider-grind__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-grind__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-grind__input::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 22px; }
.slider-grind__input::-moz-range-thumb { width: 26px; height: 22px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-grind__arm { transition: none; }
}function initSliderGrind(root) {
var input = root.querySelector('.slider-grind__input');
var grind = root.querySelector('.slider-grind__name');
var brew = root.querySelector('.slider-grind__brew');
var stops = [['Coarse', 'French press'], ['Medium', 'Pour-over'], ['Fine', 'Espresso'], ['Powder', 'Turkish']];
function update() {
var v = +input.value / 100;
var stop = stops[Math.round(v * 3)];
// The crank turns and the grounds under the loupe shrink, both from this one value.
root.style.setProperty('--slider-grind-v', v);
grind.textContent = stop[0];
brew.textContent = stop[1];
input.setAttribute('aria-valuetext', stop[0] + ', for ' + stop[1]);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-grind').forEach(initSliderGrind);Goal: Create a "Grind" slider.
Visual behavior: A hand coffee grinder: the crank turns as you drag and the grounds under the loupe go from coarse to powder, with the brew method that suits them.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The crank arm turns in a plane tipped toward you with perspective, so it swings round the shaft; three full turns cover the range.
- The grounds are four layers of radial-gradient particles on tiles of unrelated sizes, turned off the grid, so no row lines up. One variable shrinks every tile from coarse lumps to powder.
- The handle is a coffee bean, and the reading names the grind and the brew method for it.
Accessibility: A native range input named "Grind size" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the crank turns without easing.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Grind" slider.
Visual behavior: A hand coffee grinder: the crank turns as you drag and the grounds under the loupe go from coarse to powder, with the brew method that suits them.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The crank arm turns in a plane tipped toward you with perspective, so it swings round the shaft; three full turns cover the range.
- The grounds are four layers of radial-gradient particles on tiles of unrelated sizes, turned off the grid, so no row lines up. One variable shrinks every tile from coarse lumps to powder.
- The handle is a coffee bean, and the reading names the grind and the brew method for it.
Accessibility: A native range input named "Grind size" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the crank turns without easing.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A hand coffee grinder: the crank turns as you drag and the grounds under the loupe go from coarse to powder, with the brew method that suits them.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous
- Techniques
- css variables, radial gradient, perspective, 3d transform
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Grind size" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the crank turns without easing.