Flipbook
import { useState, type CSSProperties } from 'react';
const FRAMES = 24;
const BALL = 8;
// A ball dropped from the top left bouncing three times across the page: one
// parabola per bounce, each lower than the last.
const ARCS = [
{ from: 0, to: 0.3, peak: 66, drop: true },
{ from: 0.3, to: 0.64, peak: 40 },
{ from: 0.64, to: 0.88, peak: 19 },
{ from: 0.88, to: 1, peak: 7 },
];
function frame(k: number) {
const t = k / (FRAMES - 1);
const arc = ARCS.find((a) => t <= a.to) ?? ARCS[ARCS.length - 1];
const u = (t - arc.from) / (arc.to - arc.from);
const h = arc.drop ? arc.peak * (1 - u * u) : arc.peak * 4 * u * (1 - u);
// Flat on the ground it squashes; just off it, moving fast, it stretches.
const sx = h < 2 ? 1.32 : h < 16 ? 0.86 : 1;
const sy = h < 2 ? 0.7 : h < 16 ? 1.18 : 1;
return { x: 22 + t * 150, h, sx, sy };
}
const ball = (k: number) => {
const f = frame(k);
return { '--x': `${f.x.toFixed(1)}px`, '--h': f.h.toFixed(1), '--sx': f.sx, '--sy': f.sy } as CSSProperties;
};
const css = `
.slider-flipbook { --paper: light-dark(#fbfcfd, #e5e6e9); --pencil: light-dark(#373a40, #121417); display: grid; gap: 14px; width: 230px; }
/* The book: a stack of pages bound on the left, the top page carrying the frame. */
.slider-flipbook__book { position: relative; height: 112px; margin: 0 8px 0 14px; }
.slider-flipbook__page { position: absolute; inset: 0; overflow: hidden; border-radius: 2px 6px 6px 2px; background: var(--paper); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--paper) 85%, light-dark(#6d7179, #7d8087)), 2px 2px 0 -0.5px color-mix(in oklab, var(--paper) 88%, light-dark(#6d7179, #7d8087)), 4px 4px 0 -1px color-mix(in oklab, var(--paper) 80%, light-dark(#6d7179, #7d8087)), 6px 6px 0 -1.5px color-mix(in oklab, var(--paper) 72%, light-dark(#6d7179, #7d8087)), 0 12px 20px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
.slider-flipbook__spine { position: absolute; left: -10px; top: -3px; bottom: -3px; width: 16px; border-radius: 4px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41) 60%, light-dark(#6d7179, #7d8087)); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#fbfcfd, #121417) 25%, transparent); z-index: 1; }
.slider-flipbook__ground { position: absolute; left: 12px; right: 12px; top: 92px; height: 1.5px; border-radius: 1px; background: var(--pencil); opacity: 0.55; }
.slider-flipbook__no { position: absolute; right: 10px; top: 7px; color: var(--pencil); opacity: 0.55; font: italic 600 12px/1 Georgia, 'Times New Roman', serif; font-variant-numeric: tabular-nums; }
.slider-flipbook__shadow { position: absolute; left: var(--x); top: 90px; width: 18px; height: 4px; translate: -50% 0; border-radius: 50%; background: var(--pencil); opacity: calc(0.28 - var(--h) / 300); scale: calc(1 - var(--h) / 110) 1; }
.slider-flipbook__ball { position: absolute; left: var(--x); top: calc(92px - var(--h) * 1px); width: ${2 * BALL}px; height: ${2 * BALL}px; translate: -50% -100%; transform-origin: 50% 100%; scale: var(--sx) var(--sy); border-radius: 50%; background: radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--pencil) 55%, var(--paper)), var(--pencil) 70%); }
/* The two frames before this one, as an animator sees them on the light table. */
.slider-flipbook__ball--ghost { background: none; box-shadow: inset 0 0 0 1.5px var(--pencil); opacity: 0.32; }
.slider-flipbook__ball--ghost2 { opacity: 0.14; }
/* A page flicks past at every step. */
.slider-flipbook__flick { position: absolute; inset: 0; transform-origin: 100% 50%; background: linear-gradient(90deg, transparent 40%, color-mix(in oklab, var(--paper) 70%, light-dark(#6d7179, #7d8087)) 92%, color-mix(in oklab, var(--paper) 55%, light-dark(#6d7179, #7d8087))); opacity: 0; pointer-events: none; }
.slider-flipbook[data-flip='a'] .slider-flipbook__flick { animation: slider-flipbook-flick-a 0.24s ease-out; }
.slider-flipbook[data-flip='b'] .slider-flipbook__flick { animation: slider-flipbook-flick-b 0.24s ease-out; }
/* The track is a strip of film, a frame per cell. */
.slider-flipbook__strip { position: relative; height: 24px; border-radius: 3px; background: radial-gradient(circle, light-dark(#fbfcfd, #121417) 1.2px, transparent 1.6px) 0 2px / ${(230 / FRAMES).toFixed(3)}px 20px repeat-x, radial-gradient(circle, light-dark(#fbfcfd, #121417) 1.2px, transparent 1.6px) 0 20px / ${(230 / FRAMES).toFixed(3)}px 20px repeat-x, light-dark(#dbdde1, #2e3035); }
.slider-flipbook__strip::before { content: ''; position: absolute; left: 0; right: 0; top: 6px; bottom: 6px; background: repeating-linear-gradient(90deg, light-dark(#ebedf0, #232529) 0 ${(230 / FRAMES - 1.5).toFixed(3)}px, light-dark(#cbced3, #393c41) ${(230 / FRAMES - 1.5).toFixed(3)}px ${(230 / FRAMES).toFixed(3)}px); }
.slider-flipbook__seen { position: absolute; left: 0; top: 6px; bottom: 6px; width: calc((var(--slider-flipbook-k) + 1) * 100% / ${FRAMES}); background: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); }
.slider-flipbook__gate { position: absolute; top: -3px; bottom: -3px; left: calc(var(--slider-flipbook-k) * 100% / ${FRAMES}); width: calc(100% / ${FRAMES}); box-sizing: border-box; border: 2px solid light-dark(#373a40, #f1f2f3); border-radius: 3px; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); }
.slider-flipbook:has(.slider-flipbook__input:focus-visible) .slider-flipbook__gate { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.slider-flipbook__input { position: absolute; inset: -4px 0; width: 100%; height: calc(100% + 8px); margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-flipbook__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${(230 / FRAMES).toFixed(3)}px; height: 32px; }
.slider-flipbook__input::-moz-range-thumb { width: ${(230 / FRAMES).toFixed(3)}px; height: 32px; border: 0; }
.slider-flipbook__read { display: flex; justify-content: space-between; margin-top: -6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.slider-flipbook__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
@keyframes slider-flipbook-flick-a { from { opacity: 0.9; transform: scaleX(1); } to { opacity: 0; transform: scaleX(0.1); } }
@keyframes slider-flipbook-flick-b { from { opacity: 0.9; transform: scaleX(1); } to { opacity: 0; transform: scaleX(0.1); } }
@media (prefers-reduced-motion: reduce) {
.slider-flipbook__flick { animation: none !important; }
}
`;
export function Preview() {
const [k, setK] = useState(9);
return (
<>
<style href="kit-sliders-flipbook" precedence="kit">
{css}
</style>
<div className="slider-flipbook" data-flip={k % 2 ? 'a' : 'b'} style={{ '--slider-flipbook-k': k } as CSSProperties}>
<div className="slider-flipbook__book" aria-hidden="true">
<span className="slider-flipbook__page">
<span className="slider-flipbook__ground" />
<span className="slider-flipbook__shadow" style={ball(k)} />
<span className="slider-flipbook__ball slider-flipbook__ball--ghost slider-flipbook__ball--ghost2" style={ball(Math.max(0, k - 2))} />
<span className="slider-flipbook__ball slider-flipbook__ball--ghost" style={ball(Math.max(0, k - 1))} />
<span className="slider-flipbook__ball" style={ball(k)} />
<span className="slider-flipbook__no">{String(k + 1).padStart(2, '0')}</span>
<span className="slider-flipbook__flick" />
</span>
<span className="slider-flipbook__spine" />
</div>
<div className="slider-flipbook__strip">
<span className="slider-flipbook__seen" aria-hidden="true" />
<span className="slider-flipbook__gate" aria-hidden="true" />
<input className="slider-flipbook__input" type="range" min={0} max={FRAMES - 1} value={k} onChange={(e) => setK(+e.target.value)} aria-label="Frame" aria-valuetext={`Frame ${k + 1} of ${FRAMES}`} />
</div>
<div className="slider-flipbook__read" aria-hidden="true">
<span>
Frame <b>{k + 1}</b> of {FRAMES}
</span>
<span>12 fps</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderFlipbook(el) on the mounted element instead.
<div class="slider-flipbook" data-flip="a" style="--slider-flipbook-k:9">
<div class="slider-flipbook__book" aria-hidden="true">
<span class="slider-flipbook__page">
<span class="slider-flipbook__ground"></span>
<span class="slider-flipbook__shadow" style="--x:80.7px;--h:31.4;--sx:1;--sy:1"></span>
<span class="slider-flipbook__ball slider-flipbook__ball--ghost slider-flipbook__ball--ghost2" style="--x:67.7px;--h:2.0;--sx:0.86;--sy:1.18"></span>
<span class="slider-flipbook__ball slider-flipbook__ball--ghost" style="--x:74.2px;--h:19.3;--sx:1;--sy:1"></span>
<span class="slider-flipbook__ball" style="--x:80.7px;--h:31.4;--sx:1;--sy:1"></span>
<span class="slider-flipbook__no">10</span>
<span class="slider-flipbook__flick"></span>
</span>
<span class="slider-flipbook__spine"></span>
</div>
<div class="slider-flipbook__strip">
<span class="slider-flipbook__seen" aria-hidden="true"></span>
<span class="slider-flipbook__gate" aria-hidden="true"></span>
<input class="slider-flipbook__input" type="range" min="0" max="23" aria-label="Frame" aria-valuetext="Frame 10 of 24" value="9"/>
</div>
<div class="slider-flipbook__read" aria-hidden="true">
<span>
Frame
<b>10</b>
of 24
</span>
<span>12 fps</span>
</div>
</div>.slider-flipbook { --paper: light-dark(#fbfcfd, #e5e6e9); --pencil: light-dark(#373a40, #121417); display: grid; gap: 14px; width: 230px; }
/* The book: a stack of pages bound on the left, the top page carrying the frame. */
.slider-flipbook__book { position: relative; height: 112px; margin: 0 8px 0 14px; }
.slider-flipbook__page { position: absolute; inset: 0; overflow: hidden; border-radius: 2px 6px 6px 2px; background: var(--paper); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--paper) 85%, light-dark(#6d7179, #7d8087)), 2px 2px 0 -0.5px color-mix(in oklab, var(--paper) 88%, light-dark(#6d7179, #7d8087)), 4px 4px 0 -1px color-mix(in oklab, var(--paper) 80%, light-dark(#6d7179, #7d8087)), 6px 6px 0 -1.5px color-mix(in oklab, var(--paper) 72%, light-dark(#6d7179, #7d8087)), 0 12px 20px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
.slider-flipbook__spine { position: absolute; left: -10px; top: -3px; bottom: -3px; width: 16px; border-radius: 4px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41) 60%, light-dark(#6d7179, #7d8087)); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#fbfcfd, #121417) 25%, transparent); z-index: 1; }
.slider-flipbook__ground { position: absolute; left: 12px; right: 12px; top: 92px; height: 1.5px; border-radius: 1px; background: var(--pencil); opacity: 0.55; }
.slider-flipbook__no { position: absolute; right: 10px; top: 7px; color: var(--pencil); opacity: 0.55; font: italic 600 12px/1 Georgia, 'Times New Roman', serif; font-variant-numeric: tabular-nums; }
.slider-flipbook__shadow { position: absolute; left: var(--x); top: 90px; width: 18px; height: 4px; translate: -50% 0; border-radius: 50%; background: var(--pencil); opacity: calc(0.28 - var(--h) / 300); scale: calc(1 - var(--h) / 110) 1; }
.slider-flipbook__ball { position: absolute; left: var(--x); top: calc(92px - var(--h) * 1px); width: 16px; height: 16px; translate: -50% -100%; transform-origin: 50% 100%; scale: var(--sx) var(--sy); border-radius: 50%; background: radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--pencil) 55%, var(--paper)), var(--pencil) 70%); }
/* The two frames before this one, as an animator sees them on the light table. */
.slider-flipbook__ball--ghost { background: none; box-shadow: inset 0 0 0 1.5px var(--pencil); opacity: 0.32; }
.slider-flipbook__ball--ghost2 { opacity: 0.14; }
/* A page flicks past at every step. */
.slider-flipbook__flick { position: absolute; inset: 0; transform-origin: 100% 50%; background: linear-gradient(90deg, transparent 40%, color-mix(in oklab, var(--paper) 70%, light-dark(#6d7179, #7d8087)) 92%, color-mix(in oklab, var(--paper) 55%, light-dark(#6d7179, #7d8087))); opacity: 0; pointer-events: none; }
.slider-flipbook[data-flip='a'] .slider-flipbook__flick { animation: slider-flipbook-flick-a 0.24s ease-out; }
.slider-flipbook[data-flip='b'] .slider-flipbook__flick { animation: slider-flipbook-flick-b 0.24s ease-out; }
/* The track is a strip of film, a frame per cell. */
.slider-flipbook__strip { position: relative; height: 24px; border-radius: 3px; background: radial-gradient(circle, light-dark(#fbfcfd, #121417) 1.2px, transparent 1.6px) 0 2px / 9.583px 20px repeat-x, radial-gradient(circle, light-dark(#fbfcfd, #121417) 1.2px, transparent 1.6px) 0 20px / 9.583px 20px repeat-x, light-dark(#dbdde1, #2e3035); }
.slider-flipbook__strip::before { content: ''; position: absolute; left: 0; right: 0; top: 6px; bottom: 6px; background: repeating-linear-gradient(90deg, light-dark(#ebedf0, #232529) 0 8.083px, light-dark(#cbced3, #393c41) 8.083px 9.583px); }
.slider-flipbook__seen { position: absolute; left: 0; top: 6px; bottom: 6px; width: calc((var(--slider-flipbook-k) + 1) * 100% / 24); background: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); }
.slider-flipbook__gate { position: absolute; top: -3px; bottom: -3px; left: calc(var(--slider-flipbook-k) * 100% / 24); width: calc(100% / 24); box-sizing: border-box; border: 2px solid light-dark(#373a40, #f1f2f3); border-radius: 3px; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); }
.slider-flipbook:has(.slider-flipbook__input:focus-visible) .slider-flipbook__gate { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.slider-flipbook__input { position: absolute; inset: -4px 0; width: 100%; height: calc(100% + 8px); margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-flipbook__input::-webkit-slider-thumb { -webkit-appearance: none; width: 9.583px; height: 32px; }
.slider-flipbook__input::-moz-range-thumb { width: 9.583px; height: 32px; border: 0; }
.slider-flipbook__read { display: flex; justify-content: space-between; margin-top: -6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.slider-flipbook__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
@keyframes slider-flipbook-flick-a { from { opacity: 0.9; transform: scaleX(1); } to { opacity: 0; transform: scaleX(0.1); } }
@keyframes slider-flipbook-flick-b { from { opacity: 0.9; transform: scaleX(1); } to { opacity: 0; transform: scaleX(0.1); } }
@media (prefers-reduced-motion: reduce) {
.slider-flipbook__flick { animation: none !important; }
}function initSliderFlipbook(root) {
var input = root.querySelector('.slider-flipbook__input');
var balls = root.querySelectorAll('.slider-flipbook__ball'); // two frames back, one back, this one
var shadow = root.querySelector('.slider-flipbook__shadow');
var number = root.querySelector('.slider-flipbook__no');
var count = root.querySelector('.slider-flipbook__read b');
var frames = +input.max + 1;
// A ball dropped from the top left bouncing three times across the page.
var arcs = [{ from: 0, to: 0.3, peak: 66, drop: true }, { from: 0.3, to: 0.64, peak: 40 }, { from: 0.64, to: 0.88, peak: 19 }, { from: 0.88, to: 1, peak: 7 }];
function place(el, k) {
var t = k / (frames - 1);
var arc = arcs.filter(function (a) { return t <= a.to; })[0] || arcs[arcs.length - 1];
var u = (t - arc.from) / (arc.to - arc.from);
var h = arc.drop ? arc.peak * (1 - u * u) : arc.peak * 4 * u * (1 - u);
// Flat on the ground it squashes; just off it, moving fast, it stretches.
el.style.setProperty('--x', (22 + t * 150).toFixed(1) + 'px');
el.style.setProperty('--h', h.toFixed(1));
el.style.setProperty('--sx', h < 2 ? 1.32 : h < 16 ? 0.86 : 1);
el.style.setProperty('--sy', h < 2 ? 0.7 : h < 16 ? 1.18 : 1);
}
function update() {
var k = +input.value;
root.style.setProperty('--slider-flipbook-k', k);
// Switching between two identical animations replays the page flick on every step.
root.setAttribute('data-flip', k % 2 ? 'a' : 'b');
place(shadow, k);
place(balls[0], Math.max(0, k - 2));
place(balls[1], Math.max(0, k - 1));
place(balls[2], k);
number.textContent = String(k + 1).padStart(2, '0');
count.textContent = k + 1;
input.setAttribute('aria-valuetext', 'Frame ' + (k + 1) + ' of ' + frames);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-flipbook').forEach(initSliderFlipbook);Goal: Create a "Flipbook" slider.
Visual behavior: Scrub a flipbook frame by frame: a ball bounces across the page with squash on each landing, the last two frames ghosted behind it and a page flicking at every step.
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.
- Each of the 24 frames is worked out from four parabolas, one per bounce, each lower than the last. On the ground the ball squashes and just off it, moving fast, it stretches.
- The two frames before this one are drawn as outlines, the way an animator sees them on a light table, and the shadow shrinks with height.
- The track is a strip of film with a cell per frame; the handle is the frame gate. A page flick replays at every step by switching between two identical animations.
Accessibility: A native range input named "Frame" 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 page does not flick.
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 "Flipbook" slider.
Visual behavior: Scrub a flipbook frame by frame: a ball bounces across the page with squash on each landing, the last two frames ghosted behind it and a page flicking at every step.
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.
- Each of the 24 frames is worked out from four parabolas, one per bounce, each lower than the last. On the ground the ball squashes and just off it, moving fast, it stretches.
- The two frames before this one are drawn as outlines, the way an animator sees them on a light table, and the shadow shrinks with height.
- The track is a strip of film with a cell per frame; the handle is the frame gate. A page flick replays at every step by switching between two identical animations.
Accessibility: A native range input named "Frame" 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 page does not flick.
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
Scrub a flipbook frame by frame: a ball bounces across the page with squash on each landing, the last two frames ghosted behind it and a page flicking at every step.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring
- Techniques
- css variables, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Frame" 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 page does not flick.