Chocolate

No dependencies
Code format
React + Tailwind
import { useState, type CSSProperties } from 'react';

const COLS = 4;
const ROWS = 3;
const SQ = 24;
const GAP = 3;
const N = COLS * ROWS;
const BAR = { x: 12, y: 18 };
// Squares come off from the far end of the bar, a column at a time.
const order = Array.from({ length: N }, (_, k) => ({ col: COLS - 1 - Math.floor(k / ROWS), row: k % ROWS }));
// Where each broken square lands: two loose stacks, a little crooked.
const PILE = Array.from({ length: N }, (_, k) => ({
  x: 150 + (k % 2) * 34 + (((k * 7) % 5) - 2) * 2,
  y: 64 - Math.floor(k / 2) * 6 + (((k * 3) % 4) - 1.5),
  r: (((k * 11) % 9) - 4) * 4,
}));
function square(k: number, taken: boolean) {
  const { col, row } = order[k];
  const home = { x: BAR.x + col * (SQ + GAP), y: BAR.y + row * (SQ + GAP) };
  const p = PILE[k];
  return { left: home.x, top: home.y, '--dx': `${taken ? p.x - home.x : 0}px`, '--dy': `${taken ? p.y - home.y : 0}px`, '--r': `${taken ? p.r : 0}deg`, zIndex: taken ? 20 + k : 1 } as CSSProperties;
}

const css = `
  .slider-chocolate { --choc: light-dark(#50535b, #393c41); --hi: light-dark(#6d7179, #7d8087); --lo: light-dark(#42454c, #232529); display: grid; gap: 12px; width: 230px; }
  .slider-chocolate__table { position: relative; height: 112px; }
  .slider-chocolate__sq { position: absolute; width: ${SQ}px; height: ${SQ}px; border-radius: 3px; background: var(--choc); box-shadow: inset 2px 2px 0 color-mix(in oklab, var(--hi) 70%, transparent), inset -2px -2px 0 var(--lo), 0 3px 4px -2px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)); translate: var(--dx) var(--dy); rotate: var(--r); transition: translate 0.45s cubic-bezier(0.3, 1.35, 0.5, 1), rotate 0.45s ease-out; }
  .slider-chocolate__sq::before { content: ''; position: absolute; inset: 5px; border-radius: 2px; background: linear-gradient(135deg, color-mix(in oklab, var(--hi) 40%, var(--choc)), var(--choc) 60%); }
  /* The wrapper: a paper sleeve over foil, holding the end of the bar. */
  .slider-chocolate__sleeve { position: absolute; left: 4px; top: 10px; width: 36px; height: 94px; z-index: 10; border-radius: 3px; background: light-dark(#fbfcfd, #e5e6e9); box-shadow: 4px 0 0 -1px light-dark(#6d7179, #7d8087), 0 6px 10px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
  .slider-chocolate__sleeve::after { content: ''; position: absolute; right: -6px; top: 0; bottom: 0; width: 6px; background: linear-gradient(light-dark(#5f626a, #a7abb2), light-dark(#cbced3, #393c41), light-dark(#5f626a, #a7abb2), light-dark(#cbced3, #393c41)); clip-path: polygon(0 0, 100% 3%, 40% 8%, 100% 14%, 30% 20%, 100% 27%, 45% 33%, 100% 40%, 35% 47%, 100% 54%, 40% 61%, 100% 68%, 30% 75%, 100% 82%, 45% 89%, 100% 95%, 0 100%); }
  .slider-chocolate__label { position: absolute; left: 50%; top: 50%; translate: -50% -50%; rotate: -90deg; color: light-dark(#373a40, #121417); font: 700 13px/1 Georgia, 'Times New Roman', serif; letter-spacing: 0.08em; white-space: nowrap; }
  .slider-chocolate__read { display: flex; align-items: baseline; gap: 6px; }
  .slider-chocolate__count { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
  .slider-chocolate__of { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
  .slider-chocolate__track { position: relative; height: 22px; }
  .slider-chocolate__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
  .slider-chocolate__notch { position: absolute; top: 15px; width: 1px; height: 5px; background: light-dark(#cbced3, #393c41); }
  /* The handle is a square of the bar. */
  .slider-chocolate__thumb { position: absolute; top: 1px; left: calc(var(--slider-chocolate-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 4px; background: var(--choc); box-shadow: inset 2px 2px 0 color-mix(in oklab, var(--hi) 70%, transparent), inset -2px -2px 0 var(--lo), 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); }
  .slider-chocolate:has(.slider-chocolate__input:focus-visible) .slider-chocolate__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
  .slider-chocolate__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
  .slider-chocolate__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 22px; }
  .slider-chocolate__input::-moz-range-thumb { width: 20px; height: 22px; border: 0; }
  @media (prefers-reduced-motion: reduce) {
    .slider-chocolate__sq { transition: none; }
  }
`;

export function Preview() {
  const [n, setN] = useState(4);
  return (
    <>
      <style href="kit-sliders-chocolate" precedence="kit">
        {css}
      </style>
      <div className="slider-chocolate" style={{ '--slider-chocolate-v': (n / N).toFixed(4) } as CSSProperties}>
        <div className="slider-chocolate__table" aria-hidden="true">
          {order.map((_, k) => (
            <span key={k} className="slider-chocolate__sq" data-taken={k < n ? '' : undefined} style={square(k, k < n)} />
          ))}
          <span className="slider-chocolate__sleeve">
            <span className="slider-chocolate__label">72% DARK</span>
          </span>
        </div>
        <div className="slider-chocolate__read" aria-hidden="true">
          <span className="slider-chocolate__count">{n}</span>
          <span className="slider-chocolate__of">
            {n === 1 ? 'square' : 'squares'} of {N}
          </span>
        </div>
        <div className="slider-chocolate__track">
          <span className="slider-chocolate__rail" aria-hidden="true" />
          {Array.from({ length: N + 1 }, (_, i) => (
            <span key={i} className="slider-chocolate__notch" style={{ left: `calc(10px + ${i / N} * (100% - 20px))` }} aria-hidden="true" />
          ))}
          <span className="slider-chocolate__thumb" aria-hidden="true" />
          <input className="slider-chocolate__input" type="range" min={0} max={N} value={n} onChange={(e) => setN(+e.target.value)} aria-label="Squares to break off" aria-valuetext={`${n} ${n === 1 ? 'square' : 'squares'} of ${N}`} />
        </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-chocolate
Source on GitHub

About this component

Pick how many squares to break off a bar: they snap off from the end and land on a little pile, and slide back if you change your mind.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, spring
Techniques
css transition, css variables
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input named "Squares to break off" 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 squares move without the spring.

More sliders

See all
Columns
Crane
Dice
Dive