Rotary Card File Testimonials with Typed Quote Cards That Tip over the Spindle and Land with a Bounce

A desk card file seen from above the front edge: a black base with a chrome strip, two chrome posts, a ridged black knob at each end of the spindle, and fifteen cream cards standing round it. The card facing you carries one client's words in typewriter type, a small portrait print held on by a wire paperclip, the surname initial in red and the name and business underneath. Coloured index tabs from the cards behind show over its top edge. Every few seconds the front card is thumbed forward. It is pushed only as far as upright, then it falls on its own weight, slaps onto the pile of card backs below the spindle and bounces twice, and the pile dips under it. The knobs roll with every card. On a phone the whole object shrinks to fit and the type on the cards is set larger to stay readable.

Code

import { useCallback, useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

/** The cards in the file. The wheel repeats them, so any number works. */
const people = [
  {
    name: "Margit Lindqvist",
    role: "Owner, Lindqvist Upholstery",
    quote: "I described the shop over the phone. Two days later it was on my screen, down to the brass tacks.",
    tab: "#e0a422",
    photo: { src: "https://images.pexels.com/photos/6625914/pexels-photo-6625914.jpeg?auto=compress&cs=tinysrgb&w=400", alt: "Smiling woman with wavy blonde hair in a quilted jacket against a lilac backdrop", tone: "#9c86c4", focus: "50% 22%" },
  },
  {
    name: "Felix Brandt",
    role: "Baker, Brandt and Crumb",
    quote: "The order form is so plain my grandfather uses it. He is ninety one and he hates everything.",
    tab: "#2f8f86",
    photo: { src: "https://images.pexels.com/photos/6345373/pexels-photo-6345373.jpeg?auto=compress&cs=tinysrgb&w=400", alt: "Young man in a striped top tilting his head with his eyes closed against a pale blue backdrop", tone: "#a9c9cf", focus: "60% 78%" },
  },
  {
    name: "Amara Diallo",
    role: "Trainer, Diallo Movement Studio",
    quote: "Class bookings used to live in my head. Now they live on one page and I get my evenings back.",
    tab: "#d9543a",
    photo: { src: "https://images.pexels.com/photos/3535468/pexels-photo-3535468.jpeg?auto=compress&cs=tinysrgb&w=400", alt: "Woman with long blonde braids resting an arm over her head against a pale yellow dotted wall", tone: "#ecd98c", focus: "58% 26%" },
  },
  {
    name: "Matteo Greco",
    role: "Chef, Osteria Greco",
    quote: "They photographed the ragu like it was a film star. Fully deserved, and the menu reads the same way.",
    tab: "#3b62c4",
    photo: { src: "https://images.pexels.com/photos/24876042/pexels-photo-24876042.jpeg?auto=compress&cs=tinysrgb&w=400", alt: "Chef in a yellow jacket, yellow hat and yellow glasses with his arms folded against a dark backdrop", tone: "#2a2014", focus: "50% 20%" },
  },
  {
    name: "Rafiq Islam",
    role: "Director, Northlight Films",
    quote: "First agency in ten years to send the files, the logins and a thank you note on the same day.",
    tab: "#7a8a3a",
    photo: { src: "https://images.pexels.com/photos/1486055/pexels-photo-1486055.jpeg?auto=compress&cs=tinysrgb&w=400", alt: "Man in dark sunglasses crossing his fists in front of his chest against a blue backdrop", tone: "#1d4f9c", focus: "56% 16%" },
  },
];

// Design pixels. Angles are measured from the reading position: 0 faces you, more is further over the top.
const N = 15; // cards on the wheel
const SW = 520; // scene
const SH = 606;
const AX = 338; // height of the spindle in the scene
const CARD_W = 400;
const LEN = 300; // spindle to the top edge of a card
const BODY = 262; // the card itself, the rest is the hub
const PITCH = 42; // how far above the file you are looking from
const BACK_STEP = 4.5; // spacing of the cards waiting behind
const FRONT_AT = 104; // where a card that has gone over comes to rest
const FRONT_STEP = 6;
const GRAVITY = 2100;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const mod = (v: number, n: number) => ((v % n) + n) % n;
const slot = (r: number) => (r === 0 ? 0 : r <= 7 ? -BACK_STEP * r : FRONT_AT + FRONT_STEP * (N - r - 1));
const TYPE = `"American Typewriter", "Courier New", Courier, monospace`;
const svg = (s: string) => `url("data:image/svg+xml,${encodeURIComponent(s)}")`;
const FIBRE = svg(
  `<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='p' x='0' y='0' width='100%' height='100%'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='9' stitchTiles='stitch'/><feColorMatrix type='matrix' values='0 0 0 0 0.35  0 0 0 0 0.25  0 0 0 0 0.1  0 0 0 0.55 -0.17'/></filter><rect width='180' height='180' filter='url(#p)'/></svg>`,
);

type Card = { phi: number; v: number; target: number; fall: number };

export default function RotaryCardFile() {
  const reduce = !!useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  const knobRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const [cur, setCur] = useState(0);
  const [narrow, setNarrow] = useState(false);
  const curRef = useRef(0);
  const sim = useRef<Card[]>(Array.from({ length: N }, (_, j) => ({ phi: slot(j), v: 0, target: slot(j), fall: 0 })));
  const env = useRef({ touched: false, inside: false, visible: true, knob: 0, knobV: 0, wake: () => {}, drag: null as null | { y: number; moved: boolean } });

  const paint = useCallback((j: number) => {
    const el = cardRefs.current[j];
    if (!el) return;
    const c = sim.current[j];
    const m = mod(c.phi, 360);
    const hidden = m > 125 && m < 322; // under the base, on its way round to the back
    el.style.visibility = hidden ? "hidden" : "visible";
    if (hidden) return;
    el.style.transform = `rotateX(${(-c.phi).toFixed(2)}deg)`;
    // the face dims as it turns away from the lamp, the back brightens as it comes up to it
    const over = m > 180 ? 0 : m;
    el.style.setProperty("--dim", (clamp(over / 95, 0, 1) * 0.5).toFixed(3));
    el.style.setProperty("--deep", clamp((over - FRONT_AT) / 24, 0, 1).toFixed(3));
  }, []);

  const step = useCallback(
    (dir: 1 | -1, byHand: boolean) => {
      if (byHand) env.current.touched = true;
      const before = curRef.current;
      curRef.current = before + dir;
      setCur(curRef.current);
      sim.current.forEach((c, j) => {
        const rOld = mod(j - before, N);
        const base = slot(mod(j - curRef.current, N));
        let t = base + 360 * Math.round((c.target - base) / 360);
        if (dir > 0 && t < c.target - 0.5) t += 360;
        if (dir < 0 && t > c.target + 0.5) t -= 360;
        c.target = t;
        if ((dir > 0 && rOld === 0) || (dir < 0 && rOld === N - 1)) c.fall = dir;
        if (reduce) {
          c.phi = t;
          c.v = 0;
          c.fall = 0;
          paint(j);
        }
      });
      env.current.wake();
    },
    [paint, reduce],
  );

  useEffect(() => {
    const root = rootRef.current;
    const scene = sceneRef.current;
    if (!root || !scene) return;
    const measure = () => {
      const sc = clamp((root.clientWidth - 8) / SW, 0.55, 1.14);
      root.style.height = `${Math.round(SH * sc + 84)}px`;
      scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
      setNarrow(root.clientWidth < 560);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(root);
    sim.current.forEach((_, j) => paint(j));
    return () => ro.disconnect();
  }, [paint]);

  useEffect(() => {
    if (reduce) return;
    const root = rootRef.current;
    if (!root) return;
    const e = env.current;
    let raf = 0;
    let last = performance.now();
    let wait = 0.5;
    const tick = (now: number) => {
      raf = 0;
      const dt = Math.min(0.03, (now - last) / 1000);
      last = now;
      if (!e.touched && !e.inside) {
        wait -= dt;
        if (wait < 0) {
          wait = 3.6;
          step(1, false);
        }
      }
      let hit = 0;
      sim.current.forEach((c, j) => {
        if (c.fall) {
          // pushed up to the top of its arc by a thumb, then it is gravity's
          const a = c.phi - PITCH;
          const push = c.fall * a < 6 ? c.fall * (GRAVITY * 0.92 + 1500) : 0;
          c.v += (GRAVITY * Math.sin((a * Math.PI) / 180) + push - 0.6 * c.v) * dt;
          c.phi += c.v * dt;
          if ((c.fall > 0 && c.phi >= c.target && c.v > 0) || (c.fall < 0 && c.phi <= c.target && c.v < 0)) {
            c.phi = c.target;
            if (Math.abs(c.v) > 120) hit = c.v;
            c.v *= -0.3;
            if (Math.abs(c.v) < 46) c.fall = 0;
          }
          paint(j);
        } else if (Math.abs(c.target - c.phi) > 0.01 || Math.abs(c.v) > 0.05) {
          c.v += (190 * (c.target - c.phi) - 21 * c.v) * dt;
          c.phi += c.v * dt;
          paint(j);
        }
      });
      // the pile it lands on gives a little
      if (hit) {
        sim.current.forEach((c) => {
          const m = mod(c.target, 360);
          if (!c.fall && (hit > 0 ? m >= FRONT_AT && m < 125 : m > 320)) c.v += hit * 0.09;
        });
      }
      e.knobV += (150 * (curRef.current * 22 - e.knob) - 19 * e.knobV) * dt;
      e.knob += e.knobV * dt;
      knobRefs.current.forEach((k) => k && (k.style.backgroundPositionY = `${e.knob.toFixed(1)}px`));
      const busy = sim.current.some((c) => c.fall || Math.abs(c.target - c.phi) > 0.01 || Math.abs(c.v) > 0.05) || Math.abs(e.knobV) > 0.2;
      if (e.visible && !document.hidden && (busy || (!e.touched && !e.inside))) raf = requestAnimationFrame(tick);
    };
    const start = () => {
      if (!raf && e.visible && !document.hidden) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    e.wake = start;
    const io = new IntersectionObserver(([en]) => {
      e.visible = en.isIntersecting;
      start();
    });
    io.observe(root);
    document.addEventListener("visibilitychange", start);
    start();
    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      document.removeEventListener("visibilitychange", start);
      e.wake = () => {};
    };
  }, [paint, reduce, step]);

  // flick the cards: pull down to send one over, push up to bring one back
  const onDown = (ev: React.PointerEvent) => {
    if (ev.button !== 0) return;
    env.current.drag = { y: ev.clientY, moved: false };
    ev.currentTarget.setPointerCapture(ev.pointerId);
  };
  const onDrag = (ev: React.PointerEvent) => {
    const d = env.current.drag;
    if (!d) return;
    const dy = ev.clientY - d.y;
    if (Math.abs(dy) > 34) {
      step(dy > 0 ? 1 : -1, true);
      d.y = ev.clientY;
      d.moved = true;
    }
  };
  const onUp = () => {
    const d = env.current.drag;
    env.current.drag = null;
    if (d && !d.moved) step(1, true);
  };

  const now = people[mod(cur, people.length)];
  const knob = (side: "left" | "right") => (
    <button
      type="button"
      onClick={() => step(side === "right" ? 1 : -1, true)}
      aria-label={side === "right" ? "Next card" : "Previous card"}
      className="group absolute h-[104px] w-[40px] rounded-[9px] focus:outline-none focus-visible:ring-4 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#c8412b] active:brightness-125"
      style={{ top: AX - 52, [side]: 0, background: "linear-gradient(180deg, #050505 0%, #2c2c2e 9%, #6d6d72 20%, #3a3a3d 31%, #1b1b1d 55%, #0c0c0d 82%, #000 100%)", boxShadow: "0 22px 26px -12px rgba(60,8,0,0.55), inset 0 0 0 1px rgba(255,255,255,0.06)" }}
    >
      <span ref={(el) => void (knobRefs.current[side === "left" ? 0 : 1] = el)} aria-hidden="true" className="absolute inset-y-0 rounded-[3px]" style={{ left: side === "left" ? 9 : 4, right: side === "left" ? 4 : 9, backgroundImage: "repeating-linear-gradient(180deg, rgba(0,0,0,0.75) 0 2px, rgba(255,255,255,0.16) 2px 3px, rgba(0,0,0,0) 3px 7.33px)", WebkitMaskImage: "linear-gradient(180deg, transparent 2%, #000 14%, #000 86%, transparent 98%)", maskImage: "linear-gradient(180deg, transparent 2%, #000 14%, #000 86%, transparent 98%)" }} />
      <span aria-hidden="true" className="absolute inset-y-[3px] w-[6px] rounded-full" style={{ [side]: 1, background: "linear-gradient(180deg, #55585e 0%, #f6f8fb 18%, #9da2aa 34%, #4a4d53 60%, #d9dde3 84%, #3b3d42 100%)" }} />
    </button>
  );

  return (
    <section ref={rootRef} aria-label="Client card file" className="relative w-full select-none overflow-hidden bg-[#c8412b]" style={{ height: SH + 84, backgroundImage: "radial-gradient(90% 70% at 50% 34%, #e2573a 0%, #cf462d 45%, #a9301d 100%)" }}>
      <style>{`
        .rotary-card-file-face::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #1a0d05; opacity: var(--dim, 0); pointer-events: none; }
        .rotary-card-file-back::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(180deg, rgba(255,255,255,0.22) 0, rgba(60,30,5,0) 26px, rgba(60,30,5,0.12) 80px, rgba(40,20,5,0.55) 210px); opacity: 1; pointer-events: none; }
        .rotary-card-file-back::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #2a1408; opacity: calc(var(--deep, 0) * 0.3); pointer-events: none; }
      `}</style>

      <div ref={sceneRef} className="absolute left-1/2 top-7 origin-top" style={{ width: SW, height: SH, transform: "translateX(-50%)" }}>
        {/* shadow on the table */}
        <div aria-hidden="true" className="absolute left-1/2 h-[70px] w-[560px] -translate-x-1/2 rounded-[50%]" style={{ top: AX + 196, background: "radial-gradient(closest-side, rgba(70,10,0,0.62), rgba(70,10,0,0.3) 55%, rgba(70,10,0,0) 100%)" }} />

        {/* top of the base running back to the posts */}
        <div aria-hidden="true" className="absolute left-1/2 -translate-x-1/2" style={{ top: AX + 70, width: 496, height: 96, background: "linear-gradient(180deg, #050505, #1c1c1e 60%, #2a2a2d)", clipPath: "polygon(4% 0, 96% 0, 100% 100%, 0 100%)" }} />
        {/* posts and the bosses the spindle runs in */}
        {[-1, 1].map((sd) => (
          <div key={sd} aria-hidden="true">
            <span className="absolute w-[16px] rounded-t-[8px]" style={{ left: SW / 2 + sd * 214 - 8, top: AX - 10, height: 150, background: "linear-gradient(90deg, #4b4e54 0%, #f3f5f8 28%, #a3a8b0 46%, #54575d 72%, #c9cdd3 90%, #55585e 100%)", boxShadow: "0 0 0 0.5px rgba(0,0,0,0.5)" }} />
            <span className="absolute h-[44px] w-[44px] rounded-full" style={{ left: SW / 2 + sd * 214 - 22, top: AX - 22, background: "conic-gradient(from 210deg, #6a6d73, #f7f8fa 12%, #8e9299 25%, #e9ecf0 40%, #5a5d63 55%, #dfe2e7 70%, #777a80 85%, #6a6d73)", boxShadow: "0 3px 5px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.25)" }} />
          </div>
        ))}
        {knob("left")}
        {knob("right")}

        {/* the wheel */}
        <div className="absolute inset-0 cursor-grab touch-none active:cursor-grabbing" style={{ perspective: 2600, perspectiveOrigin: `50% ${AX - 130}px` }} onPointerDown={onDown} onPointerMove={onDrag} onPointerUp={onUp} onPointerCancel={() => (env.current.drag = null)} onPointerEnter={() => (env.current.inside = true)} onPointerLeave={() => (env.current.inside = false)}>
          <div aria-hidden="true" className="absolute" style={{ left: SW / 2, top: AX, width: 0, height: 0, transformStyle: "preserve-3d" }}>
            {/* hub */}
            <div className="absolute rounded-[5px]" style={{ left: -206, top: -34, width: 412, height: 68, background: "linear-gradient(180deg, #000 0%, #222224 16%, #47474b 30%, #232325 46%, #0b0b0c 78%, #000 100%)" }}>
              {[-120, 84].map((x) => (
                <span key={x} className="absolute inset-y-0 w-[36px]" style={{ left: 206 + x, background: "linear-gradient(180deg, #3a3c41 0%, #eceef2 22%, #9da1a8 38%, #46484d 62%, #17181a 100%)", boxShadow: "0 0 0 1px rgba(0,0,0,0.6)" }} />
              ))}
            </div>
            <div className="absolute" style={{ transformStyle: "preserve-3d" }}>
              {sim.current.map((_, j) => {
                const p = people[mod(j, people.length)];
                const tabbed = j % 2 === 0;
                const tabX = 30 + ((j * 97) % 250);
                const tab = (
                  <span className="absolute -top-[19px] flex h-[22px] w-[74px] items-start justify-center rounded-t-[7px] pt-[3px] text-[11px] font-bold leading-none text-white" style={{ left: tabX, background: `linear-gradient(180deg, rgba(255,255,255,0.28), rgba(255,255,255,0) 60%), ${p.tab}`, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.4)", fontFamily: TYPE }}>
                    {p.name.split(" ")[1][0]}
                  </span>
                );
                return (
                  <div key={j} ref={(el) => void (cardRefs.current[j] = el)} className="absolute" style={{ left: -CARD_W / 2, top: -LEN, width: CARD_W, height: LEN, transformOrigin: "50% 100%", transformStyle: "preserve-3d", transform: `rotateX(${-slot(j)}deg)`, visibility: slot(j) > 125 ? "hidden" : "visible" }}>
                    {/* face */}
                    <div className="rotary-card-file-face absolute inset-x-0 top-0 rounded-b-[3px] rounded-t-[13px] bg-[#f4ecd9]" style={{ height: BODY, backfaceVisibility: "hidden", backgroundImage: `${FIBRE}, linear-gradient(180deg, #fbf5e6 0%, #f3ead4 62%, #d9cdb2 100%)`, boxShadow: "inset 0 0 0 1px rgba(90,60,20,0.22), inset 0 2px 0 rgba(255,255,255,0.7)" }}>
                      {tabbed && tab}
                      <span className="absolute right-[18px] top-[13px] font-serif text-[30px] font-bold leading-none text-[#c8412b]/85">{p.name.split(" ")[1][0]}</span>
                      <div className="absolute inset-x-0 top-0 px-[24px] pt-[26px]" style={{ fontFamily: TYPE, color: "#26211c" }}>
                        <div className="relative float-left mr-[18px] mt-[6px] -rotate-[3deg] bg-white p-[6px]" style={{ width: narrow ? 88 : 116, height: narrow ? 110 : 146, boxShadow: "0 1px 2px rgba(60,30,0,0.4), 0 6px 10px -4px rgba(60,30,0,0.35)" }}>
                          <div className="h-full w-full overflow-hidden" style={{ background: p.photo.tone }}>
                            <img src={p.photo.src} alt="" width={104} height={134} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: p.photo.focus }} />
                          </div>
                          <svg viewBox="0 0 22 56" className="absolute -top-[18px] left-[10px] h-[56px] w-[22px]" fill="none" strokeLinecap="round">
                            <path d="M6 44V14a5 5 0 0 1 10 0v32a8 8 0 0 1-16 0V12" transform="translate(3 2)" stroke="rgba(40,20,0,0.35)" strokeWidth="2.4" />
                            <path d="M6 44V14a5 5 0 0 1 10 0v32a8 8 0 0 1-16 0V12" transform="translate(2 0)" stroke="#c9ced6" strokeWidth="2.2" />
                            <path d="M6 44V14a5 5 0 0 1 10 0v32a8 8 0 0 1-16 0V12" transform="translate(1.5 -0.4)" stroke="#fff" strokeWidth="0.7" opacity="0.85" />
                          </svg>
                        </div>
                        <div className="overflow-hidden">
                        <p className="pr-[22px] font-medium" style={{ fontSize: narrow ? 18.5 : 17, lineHeight: narrow ? "25px" : "26px" }}>{p.quote}</p>
                        <p className="mt-[12px] font-bold" style={{ fontSize: narrow ? 14.5 : 14 }}>{p.name}</p>
                        <p className="text-[#5b4a36]" style={{ fontSize: narrow ? 12.5 : 12 }}>{p.role}</p>
                        </div>
                      </div>
                      {[92, 296].map((x) => (
                        <span key={x} className="absolute bottom-0 h-[11px] w-[12px] rounded-t-full bg-[#191310]" style={{ left: x, boxShadow: "inset 0 2px 2px rgba(0,0,0,0.9), 0 -1px 0 rgba(255,255,255,0.35)" }} />
                      ))}
                      <span className="absolute inset-x-0 bottom-0 h-[70px] rounded-b-[3px]" style={{ background: "linear-gradient(180deg, rgba(60,30,5,0), rgba(60,30,5,0.34))" }} />
                    </div>
                    {/* back */}
                    <div className="rotary-card-file-back absolute inset-x-0 top-0 rounded-b-[3px] rounded-t-[13px] bg-[#efe5cf]" style={{ height: BODY, backfaceVisibility: "hidden", transform: "rotateY(180deg)", backgroundImage: `${FIBRE}, linear-gradient(180deg, #fdf8ea, #efe5cf)`, boxShadow: "inset 0 0 0 1px rgba(90,60,20,0.25), inset 0 3px 0 rgba(255,255,255,0.85)" }}>
                      {tabbed && <span className="absolute -top-[19px] h-[22px] w-[74px] rounded-t-[7px]" style={{ right: tabX, background: `linear-gradient(180deg, rgba(255,255,255,0.35), rgba(255,255,255,0.05)), ${p.tab}` }} />}
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
        </div>

        {/* front of the base, which the cards that have gone over disappear behind */}
        <div aria-hidden="true" className="pointer-events-none absolute left-1/2 -translate-x-1/2 rounded-b-[20px] rounded-t-[10px]" style={{ top: AX + 152, width: 506, height: 84, background: "linear-gradient(180deg, #5c5c61 0%, #2b2b2e 5%, #151516 30%, #0a0a0b 72%, #000 100%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.45), inset 0 -10px 16px rgba(0,0,0,0.7), 0 18px 22px -8px rgba(50,5,0,0.6)" }}>
          <span className="absolute inset-x-[14px] top-[13px] h-[3px] rounded-full" style={{ background: "linear-gradient(90deg, #6f7379, #f4f6f9 20%, #a6abb3 50%, #f4f6f9 80%, #6f7379)" }} />
          <span className="absolute left-1/2 top-[32px] flex h-[26px] -translate-x-1/2 items-center rounded-[3px] px-[14px] text-[9px] font-bold uppercase tracking-[0.34em] text-[#2a2a2d]" style={{ background: "linear-gradient(180deg, #f1f3f6, #b9bec6 55%, #8d929a)", boxShadow: "inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,0.8)" }}>
            Kind words
          </span>
        </div>
      </div>

      <p className="pointer-events-none absolute bottom-4 left-5 text-[10px] font-bold uppercase leading-relaxed tracking-[0.22em] text-white">
        Card file
        <span className="block font-medium text-[#ffe3da]">Turn a knob or flick the cards</span>
      </p>

      <figure className="sr-only" aria-live="polite">
        <blockquote>{now.quote}</blockquote>
        <figcaption>
          {now.name}, {now.role}
        </figcaption>
      </figure>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/rotary-card-file-testimonials-with-typed-quote-cards-that-tip-over-the-spindle-and-land-with-a-bounce.json

When to use it

How to customize it

Accessibility

Questions

How does the card fall instead of just rotating?

A card in flight is treated as a rod hinged at the spindle. Its angular speed gains `GRAVITY` times the sine of its lean each frame, which is the real equation for something toppling. A push is added only until the card is a few degrees past upright, like a thumb letting go. When it reaches the pile its speed is reversed and cut to under a third, and it stops once that is too small to see.

Why are there fifteen cards for five quotes?

The wheel needs enough cards to look full from this angle: seven waiting behind, one facing you and seven gone over. The quotes repeat round it in order. Because fifteen is a multiple of five the sequence never skips. If you change the number of quotes, set `N` to a multiple of it.

Where do cards go after they disappear behind the base?

Each card keeps turning the same way under the spindle and comes up at the back of the waiting stack. That half turn happens out of sight, so the code simply hides a card between 152 and 322 degrees.

Is this built with a 3D library?

No. Each card is a plain element rotated about its bottom edge with `rotateX`, inside a parent that keeps its children in 3D. The browser sorts them by depth. The base, posts and knobs are flat shapes with gradients drawn in front of and behind that group.

Can visitors go backwards?

Yes. The left knob, or an upward flick on the cards, lifts the top card of the pile back over the spindle. It runs the same physics in reverse and settles against the waiting stack.

Related: Cork Board Testimonials with Pinned Portrait Postcards That Swing, Unpin and Turn over to a Handwritten Note, Desk Spike Testimonials with Handwritten Note Slips That Lift off One Steel Spindle and Drop onto Another, Round Blue Enamel Wall Plaque Unveiled from Under a Linen Cloth Whose Corner Is Carried Across to the Other Peg and Left Hanging in Folds.

More testimonials or all free UI components. Start for free to have a full site built around it.

More in Testimonials

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap