# Library Date Due Card and Pocket Event List That a Self Inking Dater Stamps Row by Row

> Event list drawn as a library date card in a manila pocket: a self-inking dater stamps each new date in rough ink. Free React and Tailwind code, or HTML.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/library-date-due-card-and-pocket-event-list-that-a-self-inking-dater-stamps-row-by-row).

- Page, with a live preview: https://shadcn.exchange/r/weinc/library-date-due-card-and-pocket-event-list-that-a-self-inking-dater-stamps-row-by-row
- Screenshot: https://shadcn.exchange/shot/weinc/library-date-due-card-and-pocket-event-list-that-a-self-inking-dater-stamps-row-by-row/light.png
- Registry item: https://we.inc/r/library-date-due-card-and-pocket-event-list-that-a-self-inking-dater-stamps-row-by-row.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/library-date-due-card-and-pocket-event-list-that-a-self-inking-dater-stamps-row-by-row.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/library-date-due-card-and-pocket-event-list-that-a-self-inking-dater-stamps-row-by-row.tsx`

```tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";

/** What is printed on the pocket and typed on the card. `ink` is the colour a date was stamped in. */
const library = { name: "Fennel Street Reading Room", card: "Evenings, autumn list", number: "No. 0417" };
const INKS = { violet: "#43349a", red: "#b3271c", navy: "#1c2b55" };
const dates: { date: string; what: string; where: string; ink: keyof typeof INKS; turn: number; dx: number; dy: number }[] = [
  { date: "02 OCT 2026", what: "Poetry open mic", where: "Upstairs, 7 pm", ink: "red", turn: -2.4, dx: 1, dy: 1 },
  { date: "09 OCT 2026", what: "Mending circle", where: "Bring a jumper", ink: "red", turn: 1.6, dx: -2, dy: -1 },
  { date: "16 OCT 2026", what: "Zine fair", where: "Whole ground floor", ink: "navy", turn: -0.8, dx: 2, dy: 2 },
  { date: "23 OCT 2026", what: "Ghost stories", where: "Candles, 8 pm", ink: "violet", turn: 3.1, dx: -1, dy: 0 },
  { date: "30 OCT 2026", what: "Seed swap", where: "Courtyard, noon", ink: "violet", turn: -1.9, dx: 3, dy: -2 },
  { date: "06 NOV 2026", what: "Letterpress hour", where: "Print room", ink: "navy", turn: 0.9, dx: -3, dy: 1 },
  { date: "13 NOV 2026", what: "Translators read", where: "Upstairs, 7 pm", ink: "violet", turn: -2.8, dx: 1, dy: -1 },
  { date: "20 NOV 2026", what: "Quiet disco", where: "Stacks, 9 pm", ink: "violet", turn: 2.2, dx: -1, dy: 2 },
];

// Sizes in the scene's own pixels. Two layouts: stamp beside the pocket, or under it on narrow screens.
const PW = 330; // pocket
const CW = 262; // card
const CH = 430;
const HEAD = 74; // printed heading of the card
const ROW = 37;
const OUT = 270; // how far the card travels out of the pocket
const CARD_IN = 318; // top of the card when tucked in
const FRONT_Y = 418; // top edge of the pocket front
const BACK_Y = 340;
const FLOOR = 620;
const SW = 152; // stamp
const SH = 74;
const WIDE = { W: 920, H: 684, px: 196, restX: 700, restY: 498, restTurn: 11 };
const TALL = { W: 430, H: 800, px: 50, restX: 292, restY: 706, restTurn: -7 };
type Layout = typeof WIDE;
const STAGE = "#c9c6ea";
const fibre = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.35 0 0 0 0 0.24 0 0 0 0 0.08 0 0 0 0.3 -0.06'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23n)'/%3E%3C/svg%3E\")";
const manila = "#e3c283";
const INK_FILTER = "library-date-due-card-ink";

export default function LibraryDateDueCard() {
  const reduce = !!useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const [tall, setTall] = useState(false);
  const [stamped, setStamped] = useState(6);
  const [out, setOut] = useState(true);
  const [busy, setBusy] = useState(false);
  const live = useRef({ paused: false, touched: false, busy: false, alive: true, stamped: 6, out: true });
  const L: Layout = tall ? TALL : WIDE;
  const lay = useRef(L);
  lay.current = L;

  const cardY = useMotionValue(reduce ? -OUT : -OUT + 30);
  const sx = useMotionValue(WIDE.restX);
  const sy = useMotionValue(WIDE.restY);
  const turn = useMotionValue(WIDE.restTurn);
  const lift = useMotionValue(0.35); // 0 pressed on the card, 1 carried in the hand
  const body = useTransform([sx, sy, turn, lift], ([x, y, r, l]: number[]) => `translate3d(${x - SW / 2}px, ${y - SH / 2}px, ${14 + l * 90}px) rotate(${r}deg) scale(${1 + l * 0.03})`);
  const shade = useTransform([sx, sy, turn, lift], ([x, y, r, l]: number[]) => `translate3d(${x - SW / 2 + 4 + l * 20}px, ${y - SH / 2 + 5 + l * 30}px, 11px) rotate(${r}deg) scale(${1 + l * 0.05})`);
  const shadeBlur = useTransform(lift, (l) => `blur(${5 + l * 15}px)`);
  const shadeFade = useTransform(lift, (l) => 0.5 - l * 0.22);

  // the whole flat lay is one tray that tips a few degrees: with the pointer, and slowly by itself
  const px = useSpring(0, { stiffness: 45, damping: 14 });
  const py = useSpring(0, { stiffness: 45, damping: 14 });
  const drift = useMotionValue(0);
  const tipY = useTransform([px, drift], ([a, b]: number[]) => a * 5 + b);
  const tipX = useTransform([py, drift], ([a, b]: number[]) => 5 - a * 4 + b * 0.4);
  useEffect(() => {
    if (reduce) return;
    const a = animate(drift, [-2.6, 2.6], { duration: 6, ease: "easeInOut", repeat: Infinity, repeatType: "mirror" });
    return () => a.stop();
  }, [drift, reduce]);

  const slide = useCallback(
    (to: boolean) => {
      live.current.out = to;
      setOut(to);
      if (reduce) cardY.set(to ? -OUT : 0);
      else animate(cardY, to ? -OUT : 0, { type: "spring", stiffness: 130, damping: 17, mass: 1.1 });
    },
    [cardY, reduce],
  );

  /** Carry the dater to the next empty row, press it, and bring it home. */
  const stamp = useCallback(async () => {
    const s = live.current;
    if (s.busy) return;
    if (s.stamped >= dates.length) {
      // a full card goes back in the pocket and a fresh one comes out
      s.busy = true;
      setBusy(true);
      slide(false);
      await new Promise((r) => window.setTimeout(r, reduce ? 0 : 750));
      if (!s.alive) return;
      s.stamped = 4;
      setStamped(4);
      slide(true);
      s.busy = false;
      setBusy(false);
      return;
    }
    s.busy = true;
    setBusy(true);
    if (!s.out) {
      slide(true);
      await new Promise((r) => window.setTimeout(r, reduce ? 0 : 520));
    }
    const n = s.stamped;
    const row = dates[n];
    const now = lay.current;
    const tx = now.px + (PW - CW) / 2 + 53 + row.dx;
    const ty = CARD_IN - OUT + HEAD + n * ROW + ROW / 2 + row.dy;
    if (!reduce) {
      const go = { duration: 0.46, ease: [0.3, 0.7, 0.3, 1] } as const;
      animate(lift, 1, { duration: 0.2 });
      animate(turn, row.turn, go);
      animate(sx, tx, go);
      await animate(sy, ty, go);
      await animate(lift, 0, { duration: 0.1, ease: "easeIn" });
    }
    if (!s.alive) return;
    s.stamped = n + 1;
    setStamped(n + 1);
    if (!reduce) {
      await new Promise((r) => window.setTimeout(r, 130));
      await animate(lift, 1, { duration: 0.16, ease: "easeOut" });
      const home = { type: "spring", stiffness: 120, damping: 17 } as const;
      animate(turn, now.restTurn, home);
      animate(sx, now.restX, home);
      animate(sy, now.restY, home);
      await animate(lift, 0.35, { duration: 0.5, delay: 0.25 });
    }
    s.busy = false;
    setBusy(false);
  }, [lift, reduce, slide, sx, sy, turn]);

  useEffect(() => {
    const s = live.current;
    s.alive = true;
    return () => {
      s.alive = false;
    };
  }, []);

  // the card settles the last little way as the page loads, then the dater works down the list by itself
  useEffect(() => {
    if (reduce) return;
    const settle = animate(cardY, -OUT, { type: "spring", stiffness: 120, damping: 11, delay: 0.25 });
    let timer = 0;
    const tick = () => {
      const s = live.current;
      if (s.touched) return;
      if (!s.paused && !document.hidden) stamp();
      timer = window.setTimeout(tick, s.stamped >= dates.length ? 3200 : 2500);
    };
    timer = window.setTimeout(tick, 2750);
    return () => {
      settle.stop();
      window.clearTimeout(timer);
    };
  }, [cardY, reduce, stamp]);

  useEffect(() => {
    const wrap = wrapRef.current;
    const scene = sceneRef.current;
    if (!wrap || !scene) return;
    const measure = () => {
      const isTall = wrap.clientWidth < 700;
      const now = isTall ? TALL : WIDE;
      setTall(isTall);
      if (!live.current.busy) {
        sx.set(now.restX);
        sy.set(now.restY);
        turn.set(now.restTurn);
      }
      const sc = Math.min(1.06, wrap.clientWidth / now.W);
      wrap.style.height = `${Math.round(now.H * sc)}px`;
      scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(wrap);
    return () => ro.disconnect();
  }, [sx, sy, turn]);

  const hand = () => (live.current.touched = true);
  const full = stamped >= dates.length;
  const next = dates[Math.min(stamped, dates.length - 1)];
  const cardX = L.px + (PW - CW) / 2;
  const paper = { backgroundColor: manila, backgroundImage: `linear-gradient(135deg, rgba(255,255,255,0.3), rgba(255,255,255,0) 45%, rgba(120,80,20,0.14)), ${fibre}` } as const;

  return (
    <section className="relative overflow-hidden" style={{ backgroundColor: STAGE }} onPointerEnter={() => (live.current.paused = true)}
      onPointerMove={(e) => {
        if (reduce || e.pointerType === "touch") return;
        const r = e.currentTarget.getBoundingClientRect();
        px.set(((e.clientX - r.left) / r.width) * 2 - 1);
        py.set(((e.clientY - r.top) / r.height) * 2 - 1);
      }}
      onPointerLeave={() => {
        live.current.paused = false;
        px.set(0);
        py.set(0);
      }}
      onFocus={() => (live.current.paused = true)} onBlur={() => (live.current.paused = false)}>
      <svg aria-hidden="true" width="0" height="0" className="absolute">
        <filter id={INK_FILTER} x="-6%" y="-25%" width="112%" height="150%">
          <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" result="rough" />
          <feDisplacementMap in="SourceGraphic" in2="rough" scale="1.15" xChannelSelector="R" yChannelSelector="G" result="bitten" />
          <feTurbulence type="fractalNoise" baseFrequency="0.09 0.3" numOctaves="2" seed="11" result="blot" />
          <feColorMatrix in="blot" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  2.6 0 0 0 -0.3" result="patchy" />
          <feComposite in="bitten" in2="patchy" operator="in" />
        </filter>
      </svg>
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ backgroundImage: "radial-gradient(75% 70% at 42% 34%, #dedcf6 0%, #c9c6ea 52%, #a9a5d2 100%)" }} />
      <div ref={wrapRef} className="relative mx-auto w-full overflow-hidden" style={{ height: WIDE.H }}>
        <div ref={sceneRef} className="absolute left-1/2 top-0 select-none" style={{ width: L.W, height: L.H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "1500px", perspectiveOrigin: "50% 45%" }}>
         <motion.div className="absolute inset-0" style={{ transformStyle: "preserve-3d", transformOrigin: `${L.px + PW / 2}px 400px`, rotateX: reduce ? 0 : tipX, rotateY: reduce ? 0 : tipY }}>
          {/* shadow the whole pocket throws on the table */}
          <div aria-hidden="true" className="absolute rounded-[10px]" style={{ left: L.px + 12, top: BACK_Y + 18, width: PW - 6, height: FLOOR - BACK_Y - 14, background: "rgba(40,30,90,0.5)", filter: "blur(16px)" }} />
          <div aria-hidden="true" className="absolute rounded-[6px]" style={{ left: L.px + 3, top: BACK_Y + 4, width: PW, height: FLOOR - BACK_Y, background: "rgba(40,30,90,0.45)", filter: "blur(5px)" }} />
          {/* back of the pocket */}
          <div aria-hidden="true" className="absolute rounded-t-[7px] rounded-b-[3px]" style={{ left: L.px, top: BACK_Y, width: PW, height: FLOOR - BACK_Y, transform: "translateZ(1px)", ...paper, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.55), inset 0 0 0 1px rgba(120,80,20,0.18)" }}>
            <div className="absolute inset-x-0 top-0 h-[80px]" style={{ background: "linear-gradient(180deg, rgba(90,60,10,0) 0, rgba(90,60,10,0.32) 100%)" }} />
          </div>

          {/* the card, cut off where it passes the glued bottom of the pocket */}
          <div className="pointer-events-none absolute left-0 overflow-hidden" style={{ top: -120, width: L.W, height: FLOOR + 120 - 5, transform: "translateZ(5px)" }}>
          <motion.div
            className="pointer-events-auto absolute cursor-grab touch-none active:cursor-grabbing"
            style={{ left: cardX, top: CARD_IN + 120, width: CW, height: CH, y: cardY }}
            drag={reduce ? false : "y"}
            dragConstraints={{ top: -OUT, bottom: 0 }}
            dragElastic={0.06}
            dragMomentum={false}
            onDragStart={hand}
            onDragEnd={(_, info) => slide(cardY.get() + info.velocity.y * 0.18 < -OUT / 2)}
          >
            <div className="absolute inset-0 rounded-[4px]" style={{ backgroundColor: "#f3ead3", backgroundImage: `linear-gradient(120deg, rgba(255,255,255,0.5), rgba(255,255,255,0) 40%, rgba(120,90,30,0.1)), ${fibre}`, boxShadow: "inset 0 0 0 1px rgba(120,90,40,0.2), inset 0 1px 0 rgba(255,255,255,0.8), 3px 4px 7px rgba(60,40,10,0.3), 1px 1px 1px rgba(60,40,10,0.25)" }}>
              <div className="px-[14px] pt-[13px] text-center">
                <p className="font-serif text-[9px] uppercase tracking-[0.3em] text-[#a3261b]">{library.name}</p>
                <p className="mt-[5px] font-serif text-[17px] italic leading-none text-[#2c2620]">{library.card}</p>
              </div>
              <div className="absolute inset-x-0 border-y-[1.5px] border-[#b23a2e]/80" style={{ top: HEAD - 21, height: 21 }}>
                <span className="absolute left-0 top-0 flex h-full w-[106px] items-center justify-center font-serif text-[8px] font-semibold uppercase tracking-[0.26em] text-[#a3261b]">Date</span>
                <span className="absolute left-[116px] top-0 flex h-full items-center font-serif text-[8px] font-semibold uppercase tracking-[0.26em] text-[#a3261b]">What is on</span>
              </div>
              <div aria-hidden="true" className="absolute bottom-0 w-px bg-[#b23a2e]/70" style={{ left: 106, top: HEAD - 21 }} />
              <ol className="absolute inset-x-0" style={{ top: HEAD }}>
                {dates.map((d, i) => (
                  <li key={d.date} className="relative border-b border-[#7f9fc4]/60" style={{ height: ROW }}>
                    <span className="absolute left-0 top-0 flex h-full w-[106px] items-center justify-center">
                      {i < stamped ? (
                        <span className="library-date-due-struck block whitespace-nowrap font-mono text-[12.5px] font-bold tracking-[0.02em]" style={{ color: INKS[d.ink], transform: `translate(${d.dx}px, ${d.dy}px) rotate(${d.turn}deg)`, filter: `url(#${INK_FILTER})`, mixBlendMode: "multiply" }}>
                          {d.date}
                        </span>
                      ) : (
                        <span className="sr-only">Date to be announced</span>
                      )}
                    </span>
                    <span className="absolute left-[116px] right-[8px] top-0 flex h-full flex-col justify-center font-mono leading-none text-[#2b2925]">
                      <span className="text-[11px] font-semibold tracking-[-0.01em]">{d.what}</span>
                      <span className="mt-[4px] text-[8.5px] text-[#5d564b]">{d.where}</span>
                    </span>
                  </li>
                ))}
              </ol>
              <button
                type="button"
                onClick={() => {
                  hand();
                  slide(!out);
                }}
                aria-expanded={out}
                aria-label={out ? "Tuck the card back into its pocket" : "Pull the card out of its pocket"}
                className="absolute inset-x-0 top-0 h-[52px] cursor-pointer rounded-t-[4px] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#43349a]"
              />
            </div>
          </motion.div>
          </div>

          {/* front of the pocket: a thumb notch cut in its top edge, glued down both sides */}
          <div aria-hidden="true" className="pointer-events-none absolute" style={{ left: L.px, top: FRONT_Y, width: PW, height: FLOOR - FRONT_Y, transform: "translateZ(10px)", filter: "drop-shadow(0 -1px 1px rgba(70,45,5,0.4)) drop-shadow(0 -3px 5px rgba(70,45,5,0.18))" }}>
            <div className="absolute inset-0 rounded-b-[3px]" style={{ ...paper, WebkitMaskImage: "radial-gradient(circle 31px at 50% -3px, rgba(0,0,0,0) 30px, #000 31px)", maskImage: "radial-gradient(circle 31px at 50% -3px, rgba(0,0,0,0) 30px, #000 31px)" }}>
              <div className="absolute inset-0" style={{ background: "linear-gradient(90deg, rgba(120,80,20,0.2) 0, rgba(255,255,255,0.3) 2px, rgba(255,255,255,0) 12px, rgba(120,80,20,0.16) 13px, rgba(0,0,0,0) 15px, rgba(0,0,0,0) calc(100% - 15px), rgba(120,80,20,0.16) calc(100% - 13px), rgba(255,255,255,0) calc(100% - 12px), rgba(90,60,10,0.25) 100%)" }} />
              <div className="absolute inset-x-0 top-0 h-[3px]" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.7), rgba(255,255,255,0))" }} />
              <div className="absolute inset-x-0 bottom-0 h-[16px]" style={{ background: "linear-gradient(0deg, rgba(90,60,10,0.3), rgba(90,60,10,0))" }} />
              <div className="absolute inset-x-[30px] top-[50px] text-center text-[#3a2a12]" style={{ mixBlendMode: "multiply" }}>
                <svg width="30" height="30" viewBox="0 0 30 30" fill="none" className="mx-auto">
                  <circle cx="15" cy="15" r="13.5" stroke="currentColor" strokeWidth="1" />
                  <path d="M8 20V11l7-3 7 3v9M8 20h14M12 20v-6M15 20v-6M18 20v-6" stroke="currentColor" strokeWidth="1" />
                </svg>
                <p className="mt-[9px] font-serif text-[12.5px] uppercase tracking-[0.24em]">{library.name}</p>
                <div className="mx-auto mt-[9px] h-px w-[150px] bg-[#3a2a12]/60" />
                <p className="mx-auto mt-[9px] max-w-[220px] font-serif text-[10.5px] italic leading-[1.5]">A date is stamped on this card each time an evening is announced. Please leave it in the pocket.</p>
              </div>
              <span className="absolute right-[20px] top-[14px] font-mono text-[10px] font-bold tracking-[0.12em] text-[#a3261b]" style={{ mixBlendMode: "multiply" }}>
                {library.number}
              </span>
            </div>
          </div>

          {/* the dater, seen from straight above */}
          <motion.div aria-hidden="true" className="pointer-events-none absolute left-0 top-0 rounded-[14px] bg-[#1f1750]" style={{ width: SW, height: SH, transform: shade, filter: shadeBlur, opacity: shadeFade }} />
          <motion.button
            type="button"
            onClick={() => {
              hand();
              stamp();
            }}
            aria-disabled={busy}
            aria-label={full ? "Put this card away and start a new one" : `Stamp the next date, ${next.date}, ${next.what}`}
            className="absolute left-0 top-0 block cursor-pointer rounded-[13px] outline-none focus-visible:ring-[3px] focus-visible:ring-[#43349a] focus-visible:ring-offset-2 focus-visible:ring-offset-[#c9c6ea]"
            style={{ width: SW, height: SH, transform: body, backgroundColor: "#17171a", backgroundImage: "linear-gradient(180deg, #4a4a52 0%, #26262b 12%, #141417 55%, #0a0a0c 100%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.4), inset 0 -2px 3px rgba(0,0,0,0.8), inset 2px 0 2px rgba(255,255,255,0.08), inset -2px 0 3px rgba(0,0,0,0.6), 0 0 0 1px #050506, 0 0 0 4px #1b1b1f, 0 0 0 5px #000, 0 1px 0 5px rgba(255,255,255,0.18)" }}
          >
            {/* ridged grips at either end */}
            {[0, 1].map((side) => (
              <span key={side} aria-hidden="true" className="absolute inset-y-[9px] w-[13px] rounded-[3px]" style={{ [side ? "right" : "left"]: 6, backgroundImage: "repeating-linear-gradient(180deg, #050506 0 1.5px, #3c3c44 1.5px 2.5px, #1c1c20 2.5px 4px)", boxShadow: "inset 0 0 2px rgba(0,0,0,0.9)" }} />
            ))}
            {/* four knurled wheels that set day, month and year */}
            <span aria-hidden="true" className="absolute left-1/2 top-[7px] flex -translate-x-1/2 gap-[5px]">
              {[0, 1, 2, 3].map((w) => (
                <span key={w} className="block h-[11px] w-[19px] rounded-[2px]" style={{ backgroundImage: "repeating-linear-gradient(90deg, #6f7178 0 1px, #e9eaee 1px 2px, #9b9da5 2px 3px), linear-gradient(180deg, rgba(255,255,255,0.5), rgba(0,0,0,0.3))", backgroundBlendMode: "multiply, normal", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7), inset 0 -1px 1px rgba(0,0,0,0.6), 0 1px 1px rgba(0,0,0,0.8)" }} />
              ))}
            </span>
            {/* the index window: a paper slip under clear plastic that shows what it will print */}
            <span aria-hidden="true" className="absolute left-1/2 top-[25px] flex h-[34px] w-[104px] -translate-x-1/2 items-center justify-center overflow-hidden rounded-[4px] bg-[#f1ead8]" style={{ boxShadow: "inset 0 2px 3px rgba(0,0,0,0.55), 0 1px 0 rgba(255,255,255,0.18), 0 0 0 1.5px #050506" }}>
              <span className="font-mono text-[12px] font-bold tracking-[0.04em]" style={{ color: INKS.violet }}>
                {full ? "NEW CARD" : next.date}
              </span>
              <span className="absolute inset-0" style={{ background: "linear-gradient(115deg, rgba(255,255,255,0.75) 0%, rgba(255,255,255,0.1) 26%, rgba(255,255,255,0) 50%, rgba(0,0,0,0.12) 100%)" }} />
            </span>
            {/* a dot of the ink it is loaded with, and the long reflection of the lamp across its gloss top */}
            <span aria-hidden="true" className="absolute bottom-[5px] left-1/2 h-[5px] w-[22px] -translate-x-1/2 rounded-full" style={{ backgroundColor: INKS.violet, boxShadow: "inset 0 1px 1px rgba(255,255,255,0.5), 0 0 0 1px #050506" }} />
            <span aria-hidden="true" className="absolute inset-0 rounded-[13px]" style={{ background: "linear-gradient(108deg, rgba(255,255,255,0) 18%, rgba(255,255,255,0.16) 24%, rgba(255,255,255,0.03) 34%, rgba(255,255,255,0) 60%, rgba(255,255,255,0.07) 78%, rgba(255,255,255,0) 84%)" }} />
            <span aria-hidden="true" className="absolute inset-x-[16px] top-[2px] h-[5px] rounded-full" style={{ background: "linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.5) 30%, rgba(255,255,255,0.15) 70%, rgba(255,255,255,0))" }} />
          </motion.button>
         </motion.div>
        </div>
      </div>

      <div className="relative flex items-end justify-between gap-4 px-5 pb-5 sm:px-8 sm:pb-7">
        <p aria-live="polite" className="font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-[#3f3a75]">
          <span className="text-[#17143a]">
            {stamped} of {dates.length} dates stamped
          </span>
          <span className="block">{full ? "Card full: press the stamp for a new one" : `Next: ${next.what}`}</span>
        </p>
        <button
          type="button"
          onClick={() => {
            hand();
            stamp();
          }}
          aria-disabled={busy}
          className="min-h-[40px] shrink-0 rounded-full border border-[#17143a]/40 bg-white/40 px-4 font-mono text-[11px] uppercase tracking-[0.18em] text-[#17143a] outline-none transition-colors hover:bg-white/80 focus-visible:ring-2 focus-visible:ring-[#17143a]"
        >
          {full ? "New card" : "Stamp next"}
        </button>
      </div>
      <style>{`@keyframes library-date-due-card-strike { 0% { opacity: 0; } 100% { opacity: 1; } }
.library-date-due-struck { animation: library-date-due-card-strike 60ms linear both; }
@media (prefers-reduced-motion: reduce) { .library-date-due-struck { animation: none; } }`}</style>
    </section>
  );
}
```
