Grind

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-grind
Source on GitHub

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.

More sliders

See all
Hose
Nesting Dolls
Phase
99.1MHzClassic
Radio