Clapper

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

type Phase = 'slate' | 'clap' | 'drop' | 'leader' | 'film' | 'iris' | 'end';
const DUST = [
  [-30, -18],
  [-16, -30],
  [4, -26],
  [22, -32],
  [36, -14],
  [-40, -6],
  [46, -4],
];

const css = `
  .card-clapper { flex: none; --slate: light-dark(#42454c, #232529); --chalk: light-dark(#fbfcfd, #f1f2f3); --stick: light-dark(#373a40, #121417); --film: light-dark(#6d7179, #7d8087); position: relative; width: 248px; height: 220px; color: var(--chalk); }
  .card-clapper__screen { position: absolute; left: 0; right: 0; bottom: 8px; height: 146px; overflow: hidden; border-radius: 4px; background: var(--film); box-shadow: 0 8px 18px -8px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .card-clapper__weave { position: absolute; inset: 0; }
  .card-clapper[data-phase='leader'] .card-clapper__weave, .card-clapper[data-phase='film'] .card-clapper__weave, .card-clapper[data-phase='iris'] .card-clapper__weave { animation: card-clapper-weave 0.24s steps(3) infinite; }
  @keyframes card-clapper-weave {
    0% { transform: translate(0, 0); filter: brightness(1); }
    33% { transform: translate(0.6px, -0.8px); filter: brightness(1.1); }
    66% { transform: translate(-0.5px, 0.5px); filter: brightness(0.92); }
  }
  .card-clapper__leader { position: absolute; inset: 0; display: grid; place-items: center; background: var(--film); }
  .card-clapper__leader svg { position: absolute; inset: 0; width: 100%; height: 100%; }
  .card-clapper__cross { stroke: var(--stick); stroke-width: 1; }
  .card-clapper__circle { fill: none; stroke: var(--chalk); stroke-width: 2; }
  .card-clapper__wipe { fill: none; stroke: var(--stick); stroke-opacity: 0.38; stroke-width: 58; stroke-dasharray: 1; transform: rotate(-90deg); transform-origin: 124px 73px; animation: card-clapper-wipe 0.8s linear infinite; }
  @keyframes card-clapper-wipe {
    from { stroke-dashoffset: 1; }
    to { stroke-dashoffset: 0; }
  }
  .card-clapper__num { position: relative; font: 700 62px/1 Georgia, 'Times New Roman', serif; color: var(--chalk); }
  .card-clapper__reel { position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 46%, color-mix(in oklab, var(--chalk) 74%, var(--film)), var(--film) 92%); }
  .card-clapper__floor { position: absolute; left: 0; right: 0; bottom: 0; height: 32px; background: linear-gradient(to bottom, var(--stick), color-mix(in oklab, var(--stick) 70%, var(--film))); }
  .card-clapper__ball { position: absolute; left: 50%; bottom: 32px; width: 30px; height: 30px; margin-left: -15px; border-radius: 50%; background: var(--stick); transform-origin: 50% 100%; animation: card-clapper-bounce 0.92s linear infinite; }
  @keyframes card-clapper-bounce {
    0% { transform: translateY(0) scale(1.34, 0.66); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
    12% { transform: translateY(-10px) scale(0.88, 1.16); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
    50% { transform: translateY(-74px) scale(1, 1); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
    90% { transform: translateY(-10px) scale(0.88, 1.16); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
    100% { transform: translateY(0) scale(1.34, 0.66); }
  }
  .card-clapper__ballshadow { position: absolute; left: 50%; bottom: 14px; width: 44px; height: 8px; margin-left: -22px; border-radius: 50%; background: var(--stick); opacity: 0.6; animation: card-clapper-pool 0.92s linear infinite; }
  @keyframes card-clapper-pool {
    0%, 100% { transform: scale(1); opacity: 1; animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
    50% { transform: scale(0.42); opacity: 0.42; animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
  }
  .card-clapper__scratch { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--chalk); opacity: 0.5; animation: card-clapper-scratch 0.6s steps(5) infinite; }
  @keyframes card-clapper-scratch {
    0% { left: 18%; opacity: 0.5; }
    25% { left: 73%; opacity: 0.2; }
    50% { left: 41%; opacity: 0.55; }
    75% { left: 88%; opacity: 0.12; }
    100% { left: 26%; opacity: 0.4; }
  }
  .card-clapper__iris { position: absolute; inset: -60px; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent var(--card-clapper-open), var(--stick) calc(var(--card-clapper-open) + 1px)); }
  .card-clapper[data-phase='iris'] .card-clapper__iris { animation: card-clapper-iris 0.85s ease-in forwards; }
  @property --card-clapper-open { syntax: '<length>'; inherits: false; initial-value: 190px; }
  @keyframes card-clapper-iris {
    from { --card-clapper-open: 190px; }
    to { --card-clapper-open: 0px; }
  }
  .card-clapper__end { position: absolute; inset: 0; display: grid; place-items: center; background: var(--stick); color: var(--chalk); font: italic 700 25px/1 Georgia, 'Times New Roman', serif; animation: card-clapper-end 0.3s ease both; }
  @keyframes card-clapper-end {
    from { opacity: 0; }
  }
  .card-clapper__press { position: absolute; left: 0; right: 0; bottom: 8px; height: 178px; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; transform-origin: 50% 100%; transition: transform 0.42s cubic-bezier(0.55, 0, 0.9, 0.5), opacity 0.42s; }
  .card-clapper__press:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 6px; }
  .card-clapper[data-phase='drop'] .card-clapper__press, .card-clapper[data-phase='leader'] .card-clapper__press, .card-clapper[data-phase='film'] .card-clapper__press, .card-clapper[data-phase='iris'] .card-clapper__press, .card-clapper[data-phase='end'] .card-clapper__press { transform: translateY(250px) rotate(6deg); }
  .card-clapper[data-phase='clap'] .card-clapper__press { animation: card-clapper-jolt 0.32s cubic-bezier(0.3, 1.6, 0.5, 1) 0.14s; }
  @keyframes card-clapper-jolt {
    0% { transform: translateY(3px) scale(1.012, 0.985); }
    100% { transform: none; }
  }
  .card-clapper__stick { position: absolute; left: 0; right: 0; height: 20px; background: repeating-linear-gradient(-60deg, var(--stick) 0 15px, var(--chalk) 15px 30px); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)); }
  .card-clapper__stick--top { top: 0; border-radius: 3px 3px 0 0; transform-origin: 9px 100%; transform: rotate(-8.5deg); transition: transform 0.34s cubic-bezier(0.3, 1.4, 0.5, 1); background: repeating-linear-gradient(60deg, var(--stick) 0 15px, var(--chalk) 15px 30px); }
  .card-clapper__press:hover .card-clapper__stick--top { transform: rotate(-10.5deg); }
  .card-clapper[data-phase='clap'] .card-clapper__stick--top, .card-clapper[data-phase='drop'] .card-clapper__stick--top { transform: rotate(0); transition: transform 0.13s cubic-bezier(0.7, 0, 1, 0.5); }
  .card-clapper__stick--low { top: 20px; }
  .card-clapper__hinge { position: absolute; left: 4px; top: 14px; width: 12px; height: 12px; border-radius: 50%; background: var(--film); box-shadow: 0 0 0 1.5px var(--stick); }
  .card-clapper__board { position: absolute; left: 0; right: 0; top: 40px; bottom: 0; display: grid; grid-template-rows: 1fr 1.5fr 1fr; border-radius: 0 0 5px 5px; background: var(--slate); overflow: hidden; box-shadow: 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); }
  .card-clapper__row { display: grid; border-top: 1.5px solid color-mix(in oklab, var(--chalk) 66%, transparent); }
  .card-clapper__row:first-child { border-top: 0; }
  .card-clapper__row--three { grid-template-columns: 1.1fr 1fr 1fr; }
  .card-clapper__row--two { grid-template-columns: 1fr 1fr; }
  .card-clapper__cell { min-width: 0; padding: 3px 8px; border-left: 1.5px solid color-mix(in oklab, var(--chalk) 66%, transparent); }
  .card-clapper__cell:first-child { border-left: 0; }
  .card-clapper__label { display: block; font: 700 7.5px/1.1 system-ui, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; }
  .card-clapper__value { display: block; overflow: hidden; font-family: 'Chalkboard SE', 'Segoe Print', 'Bradley Hand', 'Comic Sans MS', cursive; font-size: 13.5px; line-height: 1.2; white-space: nowrap; }
  .card-clapper__value--big { font-size: 25px; line-height: 1.08; }
  .card-clapper__take { display: block; animation: card-clapper-take 0.3s cubic-bezier(0.3, 1.7, 0.5, 1); }
  @keyframes card-clapper-take {
    from { transform: scale(1.6); opacity: 0; }
  }
  .card-clapper__dust { position: absolute; left: 60%; top: 28px; width: 0; height: 0; pointer-events: none; }
  .card-clapper__dust i { position: absolute; width: var(--s); height: var(--s); border-radius: 50%; background: var(--chalk); animation: card-clapper-dust 0.62s ease-out forwards; }
  @keyframes card-clapper-dust {
    from { opacity: 0.9; transform: none; }
    to { opacity: 0; transform: translate(var(--x), var(--y)) scale(2.4); }
  }
  @media (prefers-reduced-motion: reduce) {
    .card-clapper *, .card-clapper__iris { animation: none !important; transition: none !important; }
  }
`;

export function ClapperCard() {
  const [phase, setPhase] = useState<Phase>('slate');
  const [take, setTake] = useState(3);
  const [num, setNum] = useState(3);
  const timers = useRef<number[]>([]);
  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const later = (ms: number, fn: () => void) => timers.current.push(window.setTimeout(fn, ms));
  const roll = () => {
    if (phase !== 'slate') return;
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setTake((t) => t + 1);
      return;
    }
    timers.current = [];
    setPhase('clap');
    later(560, () => setPhase('drop'));
    later(960, () => {
      setNum(3);
      setPhase('leader');
    });
    later(1760, () => setNum(2));
    later(2560, () => setNum(1));
    later(3360, () => setPhase('film'));
    later(6600, () => setPhase('iris'));
    later(7450, () => setPhase('end'));
    later(8800, () => {
      setTake((t) => t + 1);
      setPhase('slate');
    });
  };

  const rolling = phase === 'leader' || phase === 'film' || phase === 'iris';
  return (
    <>
      <style>
        {css}
      </style>
      <article className="card-clapper" data-phase={phase}>
        <div className="card-clapper__screen" aria-hidden="true">
          <div className="card-clapper__weave">
            {phase === 'leader' && (
              <div className="card-clapper__leader">
                <svg viewBox="0 0 248 146">
                  <circle key={num} className="card-clapper__wipe" cx="124" cy="73" r="29" pathLength={1} />
                  <path className="card-clapper__cross" d="M0 73 H248 M124 0 V146" />
                  <circle className="card-clapper__circle" cx="124" cy="73" r="60" />
                  <circle className="card-clapper__circle" cx="124" cy="73" r="52" />
                </svg>
                <span className="card-clapper__num">{num}</span>
              </div>
            )}
            {(phase === 'film' || phase === 'iris') && (
              <div className="card-clapper__reel">
                <span className="card-clapper__floor" />
                <span className="card-clapper__ballshadow" />
                <span className="card-clapper__ball" />
                <span className="card-clapper__iris" />
              </div>
            )}
            {rolling && <span className="card-clapper__scratch" />}
            {phase === 'end' && <div className="card-clapper__end">The End</div>}
          </div>
        </div>
        <button type="button" className="card-clapper__press" aria-label={`Play The Launch Film, take ${take}`} onClick={roll}>
          <span className="card-clapper__stick card-clapper__stick--low" />
          <span className="card-clapper__stick card-clapper__stick--top" />
          <span className="card-clapper__hinge" />
          {phase === 'clap' && (
            <span className="card-clapper__dust" aria-hidden="true">
              {DUST.map(([x, y], i) => (
                <i key={i} style={{ '--x': `${x}px`, '--y': `${y}px`, '--s': `${3 + (i % 3)}px`, animationDelay: '0.12s' } as CSSProperties} />
              ))}
            </span>
          )}
          <span className="card-clapper__board">
            <span className="card-clapper__row">
              <span className="card-clapper__cell">
                <span className="card-clapper__label">Production</span>
                <span className="card-clapper__value">The Launch Film</span>
              </span>
            </span>
            <span className="card-clapper__row card-clapper__row--three">
              <span className="card-clapper__cell">
                <span className="card-clapper__label">Roll</span>
                <span className="card-clapper__value card-clapper__value--big">A12</span>
              </span>
              <span className="card-clapper__cell">
                <span className="card-clapper__label">Scene</span>
                <span className="card-clapper__value card-clapper__value--big">7</span>
              </span>
              <span className="card-clapper__cell">
                <span className="card-clapper__label">Take</span>
                <span className="card-clapper__value card-clapper__value--big">
                  <span key={take} className="card-clapper__take">
                    {take}
                  </span>
                </span>
              </span>
            </span>
            <span className="card-clapper__row card-clapper__row--two">
              <span className="card-clapper__cell">
                <span className="card-clapper__label">Director</span>
                <span className="card-clapper__value">M. Holt</span>
              </span>
              <span className="card-clapper__cell">
                <span className="card-clapper__label">Runtime</span>
                <span className="card-clapper__value">0:42</span>
              </span>
            </span>
          </span>
        </button>
      </article>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/card-clapper
Source on GitHub

About this component

A video card as a film slate. Press and the sticks snap shut in a puff of chalk, the slate drops away, the old film countdown runs, and a silent reel plays until the iris closes on The End.

Dependencies
None
Difficulty
Advanced
Triggers
card, click, state change, keyframe
Techniques
keyframes, state machine, css property, stroke dashoffset, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The slate is a real button whose aria-label names the film and take; the screen is aria-hidden. Under prefers-reduced-motion nothing plays and the take simply goes up.

More cards

See all

Pick your hand

Hand of cards

Your profile6 of 9

Next: Add a photo

Jigsaw
PSRW
API keysFour on the ring. Pick one.
Keyring
Ana Kerr, Art director, 020 7946 0112
Rolodex