Reading list · 6 books
Rolodex
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardRolodex(el) on the mounted element instead.
<div class="card-rolodex" role="group" tabindex="0" aria-label="Contacts. Drag the wheel or use the arrow keys.">
<span class="card-rolodex__leg card-rolodex__leg--l" aria-hidden="true"></span>
<span class="card-rolodex__leg card-rolodex__leg--r" aria-hidden="true"></span>
<span class="card-rolodex__base" aria-hidden="true"></span>
<div class="card-rolodex__scene" aria-hidden="true">
<div class="card-rolodex__drum">
<div class="card-rolodex__leaf" style="transform:rotateX(0.00deg);visibility:visible">
<span class="card-rolodex__tab" style="--tab:10px">A</span>
<span class="card-rolodex__name">Ana Kerr</span>
<span class="card-rolodex__role">Art director</span>
<span class="card-rolodex__tel">020 7946 0112</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(11.00deg);visibility:visible">
<span class="card-rolodex__tab" style="--tab:38px">B</span>
<span class="card-rolodex__name">Ben Okafor</span>
<span class="card-rolodex__role">Sound design</span>
<span class="card-rolodex__tel">020 7946 0348</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(20.00deg);visibility:visible">
<span class="card-rolodex__tab" style="--tab:66px">C</span>
<span class="card-rolodex__name">Cleo Marsh</span>
<span class="card-rolodex__role">Producer</span>
<span class="card-rolodex__tel">020 7946 0277</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(29.00deg);visibility:visible">
<span class="card-rolodex__tab" style="--tab:94px">D</span>
<span class="card-rolodex__name">Dana Kole</span>
<span class="card-rolodex__role">Product</span>
<span class="card-rolodex__tel">020 7946 0591</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(38.00deg);visibility:hidden">
<span class="card-rolodex__tab" style="--tab:122px">E</span>
<span class="card-rolodex__name">Eli Novak</span>
<span class="card-rolodex__role">Rigging</span>
<span class="card-rolodex__tel">020 7946 0164</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(-148.00deg);visibility:hidden">
<span class="card-rolodex__tab" style="--tab:10px">F</span>
<span class="card-rolodex__name">Faye Lund</span>
<span class="card-rolodex__role">Storyboards</span>
<span class="card-rolodex__tel">020 7946 0425</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(-136.00deg);visibility:hidden">
<span class="card-rolodex__tab" style="--tab:38px">G</span>
<span class="card-rolodex__name">Gus Ito</span>
<span class="card-rolodex__role">Colour</span>
<span class="card-rolodex__tel">020 7946 0733</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(-124.00deg);visibility:hidden">
<span class="card-rolodex__tab" style="--tab:66px">H</span>
<span class="card-rolodex__name">Hana Sato</span>
<span class="card-rolodex__role">Motion lead</span>
<span class="card-rolodex__tel">020 7946 0859</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(-112.00deg);visibility:visible">
<span class="card-rolodex__tab" style="--tab:94px">I</span>
<span class="card-rolodex__name">Ivo Reyes</span>
<span class="card-rolodex__role">Compositing</span>
<span class="card-rolodex__tel">020 7946 0206</span>
</div>
<div class="card-rolodex__leaf" style="transform:rotateX(-100.00deg);visibility:visible">
<span class="card-rolodex__tab" style="--tab:122px">J</span>
<span class="card-rolodex__name">Juno Park</span>
<span class="card-rolodex__role">Editor</span>
<span class="card-rolodex__tel">020 7946 0640</span>
</div>
</div>
</div>
<span class="card-rolodex__axle" aria-hidden="true"></span>
<button type="button" class="card-rolodex__knob card-rolodex__knob--l" aria-label="Previous contact"></button>
<button type="button" class="card-rolodex__knob card-rolodex__knob--r" aria-label="Next contact"></button>
<span class="card-rolodex__sr" role="status">Ana Kerr, Art director, 020 7946 0112</span>
</div>.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; }function initCardRolodex(root) {
var leaves = root.querySelectorAll('.card-rolodex__leaf');
var status = root.querySelector('.card-rolodex__sr');
var prev = root.querySelector('.card-rolodex__knob--l');
var next = root.querySelector('.card-rolodex__knob--r');
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var N = leaves.length;
var pos = 0;
var goal = 0;
var vel = 0;
var raf = 0;
var last = 0;
var dragging = false;
var startY = 0;
var startPos = 0;
var shown = 0;
function wrap(d) {
return (((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) {
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;
}
function paint() {
leaves.forEach(function (leaf, i) {
var d = wrap(i - pos);
var hidden = d < -2.6 || d > 3.6;
leaf.style.visibility = hidden ? 'hidden' : 'visible';
if (hidden) return;
var 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) + ')';
});
root.style.setProperty('--turn', ((pos * 40) % 360).toFixed(1) + 'deg');
var near = ((Math.round(pos) % N) + N) % N;
if (near !== shown) {
shown = near;
var leaf = leaves[near];
status.textContent = leaf.querySelector('.card-rolodex__name').textContent + ', ' + leaf.querySelector('.card-rolodex__role').textContent + ', ' + leaf.querySelector('.card-rolodex__tel').textContent;
}
}
function tick(t) {
var dt = Math.min(0.033, (t - last) / 1000);
last = t;
if (!dragging) {
vel += ((goal - pos) * 90 - vel * 13) * dt;
pos += vel * dt;
}
paint();
raf = dragging || Math.abs(goal - pos) > 0.002 || Math.abs(vel) > 0.01 ? requestAnimationFrame(tick) : 0;
}
function wake() {
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
}
function nudge(by) {
goal += by;
if (calm) {
pos = goal;
vel = 0;
paint();
} else wake();
}
function onPrev() {
nudge(-1);
}
function onNext() {
nudge(1);
}
function onDown(e) {
if (e.target.closest('button')) return;
dragging = true;
startY = e.clientY;
startPos = pos;
root.setPointerCapture(e.pointerId);
wake();
}
function onMove(e) {
// Dragging down pulls the next card over, like a thumb on the wheel.
if (dragging) pos = startPos + (e.clientY - startY) / 46;
}
function onUp() {
if (!dragging) return;
dragging = false;
goal = Math.round(pos);
vel = 0;
wake();
}
function onKey(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault();
nudge(1);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
nudge(-1);
}
}
prev.addEventListener('click', onPrev);
next.addEventListener('click', onNext);
root.addEventListener('pointerdown', onDown);
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerup', onUp);
root.addEventListener('pointercancel', onUp);
root.addEventListener('keydown', onKey);
paint();
return function () {
cancelAnimationFrame(raf);
prev.removeEventListener('click', onPrev);
next.removeEventListener('click', onNext);
root.removeEventListener('pointerdown', onDown);
root.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerup', onUp);
root.removeEventListener('pointercancel', onUp);
root.removeEventListener('keydown', onKey);
};
}
document.querySelectorAll('.card-rolodex').forEach(initCardRolodex);Goal: Create a "Rolodex" card.
Visual behavior: 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.
Technique:
- Every card hinges on the axle. One position value, sprung to a whole number, gives each card its angle from how far it is from the one being read: cards already read flop forward past the axle, and the rest stack close behind.
- The scene is clipped at the axle, so a card that flops forward disappears behind it like a flap, and a brightness filter darkens cards as they tip.
Accessibility: 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.
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 "Rolodex" card.
Visual behavior: 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.
Technique:
- Every card hinges on the axle. One position value, sprung to a whole number, gives each card its angle from how far it is from the one being read: cards already read flop forward past the axle, and the rest stack close behind.
- The scene is clipped at the axle, so a card that flops forward disappears behind it like a flap, and a brightness filter darkens cards as they tip.
Accessibility: 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.
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
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.