Flipbook

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

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

Install with shadcn

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

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.

More sliders

See all
Grind
Hose
Nesting Dolls
Phase