Chocolate
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderChocolate(el) on the mounted element instead.
<div class="slider-chocolate" style="--slider-chocolate-v:0.3333">
<div class="slider-chocolate__table" aria-hidden="true">
<span class="slider-chocolate__sq" data-taken="" style="left:93px;top:18px;--dx:53px;--dy:44.5px;--r:-16deg;z-index:20"></span>
<span class="slider-chocolate__sq" data-taken="" style="left:93px;top:45px;--dx:91px;--dy:20.5px;--r:-8deg;z-index:21"></span>
<span class="slider-chocolate__sq" data-taken="" style="left:93px;top:72px;--dx:61px;--dy:-13.5px;--r:0deg;z-index:22"></span>
<span class="slider-chocolate__sq" data-taken="" style="left:66px;top:18px;--dx:116px;--dy:39.5px;--r:8deg;z-index:23"></span>
<span class="slider-chocolate__sq" style="left:66px;top:45px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:66px;top:72px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:39px;top:18px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:39px;top:45px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:39px;top:72px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:12px;top:18px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:12px;top:45px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sq" style="left:12px;top:72px;--dx:0px;--dy:0px;--r:0deg;z-index:1"></span>
<span class="slider-chocolate__sleeve">
<span class="slider-chocolate__label">72% DARK</span>
</span>
</div>
<div class="slider-chocolate__read" aria-hidden="true">
<span class="slider-chocolate__count">4</span>
<span class="slider-chocolate__of">squares of 12</span>
</div>
<div class="slider-chocolate__track">
<span class="slider-chocolate__rail" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.08333333333333333 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.16666666666666666 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.25 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.3333333333333333 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.4166666666666667 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.5 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.5833333333333334 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.6666666666666666 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.75 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.8333333333333334 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 0.9166666666666666 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__notch" style="left:calc(10px + 1 * (100% - 20px))" aria-hidden="true"></span>
<span class="slider-chocolate__thumb" aria-hidden="true"></span>
<input class="slider-chocolate__input" type="range" min="0" max="12" aria-label="Squares to break off" aria-valuetext="4 squares of 12" value="4"/>
</div>
</div>.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: 24px; height: 24px; 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; }
}function initSliderChocolate(root) {
var input = root.querySelector('.slider-chocolate__input');
var squares = root.querySelectorAll('.slider-chocolate__sq');
var count = root.querySelector('.slider-chocolate__count');
var of = root.querySelector('.slider-chocolate__of');
var total = squares.length;
// Where each broken square lands: two loose stacks, a little crooked. The
// squares are in break order, from the far end of the bar.
function pile(k) {
return { 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 update() {
var n = +input.value;
squares.forEach(function (sq, k) {
var taken = k < n;
var p = pile(k);
sq.toggleAttribute('data-taken', taken);
sq.style.setProperty('--dx', (taken ? p.x - parseFloat(sq.style.left) : 0) + 'px');
sq.style.setProperty('--dy', (taken ? p.y - parseFloat(sq.style.top) : 0) + 'px');
sq.style.setProperty('--r', (taken ? p.r : 0) + 'deg');
sq.style.zIndex = taken ? 20 + k : 1;
});
root.style.setProperty('--slider-chocolate-v', (n / total).toFixed(4));
count.textContent = n;
of.textContent = (n === 1 ? 'square' : 'squares') + ' of ' + total;
input.setAttribute('aria-valuetext', n + (n === 1 ? ' square' : ' squares') + ' of ' + total);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-chocolate').forEach(initSliderChocolate);Goal: Create a "Chocolate" slider.
Visual behavior: 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.
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 twelve squares are in break order, a column at a time from the far end. Each knows its place in the bar and its place on the pile.
- A taken square moves by the difference on a springy curve and turns a little, so the pile stays loose and crooked.
- The near end of the bar sits in a paper sleeve over foil, and the handle is a square of the bar.
Accessibility: 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.
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 "Chocolate" slider.
Visual behavior: 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.
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 twelve squares are in break order, a column at a time from the far end. Each knows its place in the bar and its place on the pile.
- A taken square moves by the difference on a springy curve and turns a little, so the pile stays loose and crooked.
- The near end of the bar sits in a paper sleeve over foil, and the handle is a square of the bar.
Accessibility: 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.
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
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.