Rolodex

Ana Kerr, Art director, 020 7946 0112
No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';

const PEOPLE = [
  { name: 'Ana Kerr', role: 'Art director', tel: '020 7946 0112' },
  { name: 'Ben Okafor', role: 'Sound design', tel: '020 7946 0348' },
  { name: 'Cleo Marsh', role: 'Producer', tel: '020 7946 0277' },
  { name: 'Dana Kole', role: 'Product', tel: '020 7946 0591' },
  { name: 'Eli Novak', role: 'Rigging', tel: '020 7946 0164' },
  { name: 'Faye Lund', role: 'Storyboards', tel: '020 7946 0425' },
  { name: 'Gus Ito', role: 'Colour', tel: '020 7946 0733' },
  { name: 'Hana Sato', role: 'Motion lead', tel: '020 7946 0859' },
  { name: 'Ivo Reyes', role: 'Compositing', tel: '020 7946 0206' },
  { name: 'Juno Park', role: 'Editor', tel: '020 7946 0640' },
];
const N = PEOPLE.length;
const wrap = (d: number) => ((d + N / 2) % N + N) % N - N / 2;
// The angle a leaf stands at, by how far it is from the card being read: the ones
// already read have flopped forward past the axle, the rest stack up close behind.
function angleAt(d: number) {
  if (d <= -1) return -100 - (-d - 1) * 12;
  if (d < 0) return d * 100;
  if (d <= 1) return d * 11;
  return 11 + (d - 1) * 9;
}

const css = `
  .card-rolodex { flex: none; --paper: light-dark(#ebedf0, #f1f2f3); --print: light-dark(#373a40, #1a1c1f); --soft: color-mix(in oklab, var(--print) 80%, var(--paper)); --metal: light-dark(#6d7179, #7d8087); --dark: light-dark(#42454c, #2e3035); --turn: 0deg; position: relative; width: 264px; height: 226px; outline: none; touch-action: none; cursor: grab; -webkit-user-select: none; user-select: none; }
  .card-rolodex:active { cursor: grabbing; }
  .card-rolodex:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 12px; }
  .card-rolodex__scene { position: absolute; left: 0; right: 0; top: 0; height: 182px; overflow: hidden; perspective: 640px; perspective-origin: 50% -40px; }
  .card-rolodex__drum { position: absolute; left: 50%; top: 182px; width: 0; height: 0; transform-style: preserve-3d; transform: rotateX(-14deg); }
  .card-rolodex__leaf { position: absolute; left: -88px; bottom: 0; box-sizing: border-box; width: 176px; height: 140px; padding: 14px 15px 0; border-radius: 6px 6px 3px 3px; background: var(--paper); color: var(--print); transform-origin: 50% 100%; backface-visibility: visible; box-shadow: 0 0 0 1px color-mix(in oklab, var(--print) 24%, transparent); will-change: transform; }
  .card-rolodex__tab { position: absolute; top: -11px; right: var(--tab); display: grid; place-items: center; width: 20px; height: 12px; border-radius: 4px 4px 0 0; background: var(--paper); color: var(--print); font-size: 8.5px; font-weight: 700; }
  .card-rolodex__name { display: block; font-size: 18px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; }
  .card-rolodex__role { display: block; margin-top: 2px; color: var(--soft); font-size: 11px; }
  .card-rolodex__tel { display: block; margin-top: 14px; padding-top: 8px; border-top: 1px solid color-mix(in oklab, var(--print) 18%, transparent); font: 700 12px/1 'Courier New', Courier, monospace; }
  .card-rolodex__axle { position: absolute; left: 26px; right: 26px; top: 179px; height: 7px; border-radius: 4px; background: linear-gradient(to bottom, var(--metal), var(--dark)); }
  .card-rolodex__leg { position: absolute; top: 180px; width: 18px; height: 40px; background: var(--dark); }
  .card-rolodex__leg--l { left: 12px; clip-path: polygon(20% 0, 80% 0, 100% 100%, 0 100%); }
  .card-rolodex__leg--r { right: 12px; clip-path: polygon(20% 0, 80% 0, 100% 100%, 0 100%); }
  .card-rolodex__base { position: absolute; left: 6px; right: 6px; bottom: 2px; height: 11px; border-radius: 3px 3px 6px 6px; background: var(--dark); box-shadow: 0 7px 10px -4px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .card-rolodex__knob { position: absolute; top: 165px; width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: repeating-conic-gradient(from var(--turn), var(--metal) 0 10deg, var(--dark) 10deg 20deg); box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); cursor: pointer; }
  .card-rolodex__knob::after { content: ''; position: absolute; inset: 9px; border-radius: 50%; background: var(--metal); box-shadow: inset 0 1px 1px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); }
  .card-rolodex__knob:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); outline-offset: 2px; }
  .card-rolodex__knob--l { left: 3px; }
  .card-rolodex__knob--r { right: 3px; }
  .card-rolodex__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
`;

export function RolodexCard() {
  const root = useRef<HTMLDivElement>(null);
  const [current, setCurrent] = useState(0);
  const goal = useRef(0);
  const nudge = useRef<(by: number) => void>(() => {});

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const leaves = [...el.querySelectorAll<HTMLElement>('.card-rolodex__leaf')];
    let pos = 0;
    let vel = 0;
    let raf = 0;
    let last = performance.now();
    let dragging = false;
    let startY = 0;
    let startPos = 0;
    let lastShown = 0;

    const paint = () => {
      leaves.forEach((leaf, i) => {
        const d = wrap(i - pos);
        const hidden = d < -2.6 || d > 3.6;
        leaf.style.visibility = hidden ? 'hidden' : 'visible';
        if (hidden) return;
        const a = angleAt(d);
        leaf.style.transform = `rotateX(${a.toFixed(2)}deg)`;
        // Light falls off as a leaf tips away from it.
        leaf.style.filter = `brightness(${(1 - Math.min(0.5, Math.abs(a) / 220 + (d > 0 ? d * 0.07 : 0))).toFixed(3)})`;
      });
      el.style.setProperty('--turn', `${((pos * 40) % 360).toFixed(1)}deg`);
      const near = ((Math.round(pos) % N) + N) % N;
      if (near !== lastShown) {
        lastShown = near;
        setCurrent(near);
      }
    };
    const tick = (t: number) => {
      const dt = Math.min(0.033, (t - last) / 1000);
      last = t;
      if (!dragging) {
        vel += ((goal.current - pos) * 90 - vel * 13) * dt;
        pos += vel * dt;
      }
      paint();
      if (dragging || Math.abs(goal.current - pos) > 0.002 || Math.abs(vel) > 0.01) raf = requestAnimationFrame(tick);
      else raf = 0;
    };
    const wake = () => {
      if (!raf) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    nudge.current = (by: number) => {
      goal.current += by;
      if (calm) {
        pos = goal.current;
        vel = 0;
        paint();
      } else wake();
    };
    const down = (e: PointerEvent) => {
      if ((e.target as HTMLElement).closest('button')) return;
      dragging = true;
      startY = e.clientY;
      startPos = pos;
      el.setPointerCapture(e.pointerId);
      wake();
    };
    const move = (e: PointerEvent) => {
      if (!dragging) return;
      // Dragging down pulls the next card over, like a thumb on the wheel.
      pos = startPos + (e.clientY - startY) / 46;
    };
    const up = () => {
      if (!dragging) return;
      dragging = false;
      goal.current = Math.round(pos);
      vel = 0;
      wake();
    };
    el.addEventListener('pointerdown', down);
    el.addEventListener('pointermove', move);
    el.addEventListener('pointerup', up);
    el.addEventListener('pointercancel', up);
    paint();
    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener('pointerdown', down);
      el.removeEventListener('pointermove', move);
      el.removeEventListener('pointerup', up);
      el.removeEventListener('pointercancel', up);
    };
  }, []);

  const keys = (e: KeyboardEvent) => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
      e.preventDefault();
      nudge.current(1);
    }
    if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
      e.preventDefault();
      nudge.current(-1);
    }
  };

  return (
    <>
      <style>
        {css}
      </style>
      <div ref={root} className="card-rolodex" role="group" tabIndex={0} aria-label="Contacts. Drag the wheel or use the arrow keys." onKeyDown={keys}>
        <span className="card-rolodex__leg card-rolodex__leg--l" aria-hidden="true" />
        <span className="card-rolodex__leg card-rolodex__leg--r" aria-hidden="true" />
        <span className="card-rolodex__base" aria-hidden="true" />
        <div className="card-rolodex__scene" aria-hidden="true">
          <div className="card-rolodex__drum">
            {PEOPLE.map((p, i) => (
              <div key={p.name} className="card-rolodex__leaf" style={{ transform: `rotateX(${angleAt(wrap(i)).toFixed(2)}deg)`, visibility: wrap(i) < -2.6 || wrap(i) > 3.6 ? 'hidden' : 'visible' }}>
                <span className="card-rolodex__tab" style={{ ['--tab' as string]: `${10 + (i % 5) * 28}px` }}>
                  {p.name[0]}
                </span>
                <span className="card-rolodex__name">{p.name}</span>
                <span className="card-rolodex__role">{p.role}</span>
                <span className="card-rolodex__tel">{p.tel}</span>
              </div>
            ))}
          </div>
        </div>
        <span className="card-rolodex__axle" aria-hidden="true" />
        <button type="button" className="card-rolodex__knob card-rolodex__knob--l" aria-label="Previous contact" onClick={() => nudge.current(-1)} />
        <button type="button" className="card-rolodex__knob card-rolodex__knob--r" aria-label="Next contact" onClick={() => nudge.current(1)} />
        <span className="card-rolodex__sr" role="status">
          {PEOPLE[current].name}, {PEOPLE[current].role}, {PEOPLE[current].tel}
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A contact list on a real wheel. Drag it or turn the knobs: cards flop forward off the top one by one around the axle, each stopping upright to be read.

Dependencies
None
Difficulty
Advanced
Triggers
card, click, 3d, spring
Techniques
raf, spring, perspective, preserve 3d, event position
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A focusable group that takes the arrow keys, plus two named knob buttons. The wheel is aria-hidden and the current contact is read from a role="status" line. Under prefers-reduced-motion the wheel jumps to the next card.

More cards

See all

Reading list · 6 books

Bookshelf
Snow globe
Papilio machaonSwallowtail, plate IV
Specimen
Zoetrope