# Matchbox Card with a Drawer That Slides out and a Photo Print That Stands up from the Matches

> 3D card built as a matchbox: drag the drawer out of its sleeve and a photo print stands up from a bed of matches. 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/matchbox-card-with-a-drawer-that-slides-out-and-a-photo-print-that-stands-up-from-the-matches).

- Page, with a live preview: https://shadcn.exchange/r/weinc/matchbox-card-with-a-drawer-that-slides-out-and-a-photo-print-that-stands-up-from-the-matches
- Screenshot: https://shadcn.exchange/shot/weinc/matchbox-card-with-a-drawer-that-slides-out-and-a-photo-print-that-stands-up-from-the-matches/light.png
- Registry item: https://we.inc/r/matchbox-card-with-a-drawer-that-slides-out-and-a-photo-print-that-stands-up-from-the-matches.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/matchbox-card-with-a-drawer-that-slides-out-and-a-photo-print-that-stands-up-from-the-matches.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/matchbox-card-with-a-drawer-that-slides-out-and-a-photo-print-that-stands-up-from-the-matches.tsx`

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

/** Everything printed on the box and the card inside it. */
const box = {
  name: "Afterglow",
  number: "No. 7",
  line: "Forty safety matches and one print",
  cover: { src: "https://images.pexels.com/photos/7516560/pexels-photo-7516560.jpeg?auto=compress&cs=tinysrgb&w=900", alt: "Woman in a red dress standing in front of a yellow wall with a large red circle", tone: "#e3a70f", focus: "50% 34%" },
  print: { src: "https://images.pexels.com/photos/7658850/pexels-photo-7658850.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Profile of a woman lit in orange and green studio light", tone: "#b5561a", focus: "50% 30%" },
};

// Design pixels in the box's own space: x runs along the box, y across it, z up off the table.
const W = 400;
const DP = 250;
const H = 84;
const WALL = 5; // gap between sleeve and tray
const TRAY_H = 66;
const MATCH_Z = 34; // height of the bed of matches inside the tray
const OUT = 296; // travel of the drawer
const CARD_W = 184; // across the box
const CARD_L = 222; // along the box
const LIFT = 63; // degrees the print stands up
const SCENE_W = 900;
const HINGE = 64; // how far in from the drawer's outer end the print is hinged
const SCENE_H = 620;
// how one design pixel along x lands on screen for the resting camera (used to turn a drag into travel)
const TURN = 39;
const TIP = 54;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const rad = (d: number) => (d * Math.PI) / 180;

const PAPER = "#efe6d2";
const RED = "#cf2f20";
const speckle = "radial-gradient(circle at 20% 30%, rgba(255,255,255,0.16) 0 0.6px, rgba(0,0,0,0) 1px), radial-gradient(circle at 70% 60%, rgba(0,0,0,0.5) 0 0.7px, rgba(0,0,0,0) 1.1px), radial-gradient(circle at 45% 85%, rgba(255,255,255,0.1) 0 0.5px, rgba(0,0,0,0) 0.9px)";

export default function MatchboxPrintCard() {
  const reduce = !!useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState(false);
  const state = useRef({ scale: 1, dragging: false, moved: 0, lastX: 0, lastY: 0, lastT: 0, vel: 0, paused: false, touched: false });

  const out = useSpring(0, { stiffness: 150, damping: 17, mass: 1.15 });
  const lift = useSpring(0, { stiffness: 90, damping: 11, mass: 1 });
  const px = useMotionValue(0);
  const py = useMotionValue(0);
  const rz = useSpring(useTransform(px, (v) => TURN + v * 5), { stiffness: 60, damping: 14 });
  const rx = useSpring(useTransform(py, (v) => TIP - v * 4), { stiffness: 60, damping: 14 });

  const back = useTransform(out, (v) => -v);
  const mouthShade = useTransform(out, (v) => clamp(v / 50, 0, 1));
  const hinge = useTransform(lift, (v) => `translate3d(${W - HINGE}px, ${DP / 2}px, ${MATCH_Z + 5}px) rotateY(${(v * LIFT).toFixed(2)}deg)`);
  const cardShadow = useTransform(lift, (v) => 0.55 - clamp(v, 0, 1) * 0.3);
  const cardShadowX = useTransform(lift, (v) => clamp(v, 0, 1.2) * 34);
  const cardGloss = useTransform(lift, (v) => 0.1 + clamp(v, 0, 1) * 0.5);
  const tableShadowW = useTransform(out, (v) => (W + v + 44) / (W + 44));

  const slide = useCallback(
    (to: boolean) => {
      setOpen(to);
      if (reduce) {
        out.jump(to ? OUT : 0);
        lift.jump(to ? 1 : 0);
      } else out.set(to ? OUT : 0);
    },
    [lift, out, reduce],
  );

  // the print stands up once the drawer has cleared the sleeve, and lies down again as it goes back
  useEffect(() => out.on("change", (v) => !reduce && lift.set(v > OUT * 0.8 ? 1 : 0)), [lift, out, reduce]);

  useEffect(() => {
    if (reduce) {
      slide(true);
      return;
    }
    const s = state.current;
    let n = 0;
    const first = window.setTimeout(() => slide(true), 320);
    const loop = window.setInterval(() => {
      if (s.paused || s.touched || document.hidden) return;
      n += 1;
      slide(n % 4 !== 3);
    }, 1700);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(loop);
    };
  }, [reduce, slide]);

  useEffect(() => {
    const wrap = wrapRef.current;
    const scene = sceneRef.current;
    if (!wrap || !scene) return;
    const measure = () => {
      const sc = clamp(wrap.clientWidth / (SCENE_W - 40), 0.42, 1);
      state.current.scale = sc;
      wrap.style.height = `${Math.round(SCENE_H * sc + 84)}px`;
      scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(wrap);
    return () => ro.disconnect();
  }, []);

  // dragging the drawer: project the pointer's movement onto the direction the drawer runs on screen
  const axis = useMemo(() => {
    const vx = Math.cos(rad(TURN));
    const vy = Math.sin(rad(TURN)) * Math.cos(rad(TIP));
    const len = Math.hypot(vx, vy);
    return { ux: vx / len, uy: vy / len, len };
  }, []);
  const onDown = (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    const s = state.current;
    s.dragging = true;
    s.touched = true;
    s.moved = 0;
    s.vel = 0;
    s.lastX = e.clientX;
    s.lastY = e.clientY;
    s.lastT = performance.now();
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const onDrag = (e: React.PointerEvent) => {
    const s = state.current;
    if (!s.dragging) return;
    const d = ((e.clientX - s.lastX) * axis.ux + (e.clientY - s.lastY) * axis.uy) / (axis.len * s.scale);
    const now = performance.now();
    s.vel = s.vel * 0.6 + (d / Math.max(1, now - s.lastT)) * 0.4;
    s.moved += Math.abs(d);
    s.lastX = e.clientX;
    s.lastY = e.clientY;
    s.lastT = now;
    const raw = out.get() + d;
    // rubber band past either end
    out.jump(raw < 0 ? raw * 0.5 : raw > OUT ? OUT + (raw - OUT) * 0.35 : raw);
  };
  const onUp = () => {
    const s = state.current;
    if (!s.dragging) return;
    s.dragging = false;
    if (s.moved < 4) slide(!open);
    else slide(out.get() + s.vel * 260 > OUT / 2);
  };

  const onMove = (e: React.PointerEvent) => {
    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);
  };

  const matches = useMemo(
    () =>
      Array.from({ length: 17 }, (_, i) => ({
        top: 6 + i * 13.4,
        shift: [0, 5, -3, 7, 2, -4, 6, 0, 4, -2, 8, 1, -5, 3, 6, -1, 4][i],
        tilt: [0.3, -0.5, 0.2, 0.6, -0.3, 0.1, -0.6, 0.4, 0, -0.2, 0.5, -0.4, 0.2, 0.3, -0.5, 0.1, -0.1][i],
      })),
    [],
  );

  const trayW = W - WALL * 2;
  const trayD = DP - WALL * 2;
  const kraft = "linear-gradient(180deg, #c9a878, #b08b58)";

  return (
    <section className="relative overflow-hidden bg-[#0f3a30]">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(64% 60% at 44% 40%, #1f6a57 0%, #14493c 46%, #0b2b24 100%)" }} />
      <div
        ref={wrapRef}
        onPointerMove={onMove}
        onPointerEnter={() => (state.current.paused = true)}
        onPointerLeave={() => {
          state.current.paused = false;
          px.set(0);
          py.set(0);
        }}
        onFocus={() => (state.current.paused = true)}
        onBlur={() => (state.current.paused = false)}
        className="relative mx-auto w-full overflow-hidden"
        style={{ height: SCENE_H + 84 }}
      >
        <div ref={sceneRef} className="absolute left-1/2 top-0 select-none" style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "2600px", perspectiveOrigin: "50% 40%" }}>
          <motion.div className="absolute" style={{ left: SCENE_W / 2 - W / 2 - 118, top: SCENE_H / 2 - DP / 2 + 8, width: W, height: DP, transformStyle: "preserve-3d", transformOrigin: `${W / 2 + 118}px ${DP / 2}px`, rotateX: rx, rotateZ: rz }}>
            {/* shadow on the table: one under the sleeve, one that grows with the drawer */}
            <motion.div aria-hidden="true" className="absolute rounded-[10px] bg-black" style={{ left: -6, top: 4, width: W + 44, height: DP + 30, transformOrigin: "0 50%", scaleX: tableShadowW, filter: "blur(20px)", opacity: 0.5 }} />
            <div aria-hidden="true" className="absolute bg-black" style={{ left: 2, top: 2, width: W, height: DP + 4, filter: "blur(5px)", opacity: 0.55 }} />

            {/* the drawer */}
            <motion.div onPointerDown={onDown} onPointerMove={onDrag} onPointerUp={onUp} onPointerCancel={onUp} className="absolute left-0 top-0 cursor-grab touch-none active:cursor-grabbing" style={{ width: W, height: DP, transformStyle: "preserve-3d", x: out }}>
              <motion.div aria-hidden="true" className="absolute bg-black" style={{ left: WALL + 2, top: WALL + 2, width: trayW, height: trayD + 3, filter: "blur(5px)", opacity: mouthShade, transform: "translateZ(0.5px)" }} />
              {/* floor */}
              <div className="absolute" style={{ left: WALL, top: WALL, width: trayW, height: trayD, transform: "translateZ(3px)", background: "#2b1d10" }} />
              {/* far and near walls */}
              <div className="absolute" style={{ left: WALL, top: WALL - TRAY_H, width: trayW, height: TRAY_H, transformOrigin: "50% 100%", transform: "rotateX(-90deg)", background: "linear-gradient(180deg, #d9bb8b, #b6925c 60%, #7c5f37)" }} />
              <div className="absolute" style={{ left: WALL, top: DP - WALL - TRAY_H, width: trayW, height: TRAY_H, transformOrigin: "50% 100%", transform: "rotateX(-90deg)", background: kraft }}>
                <div className="absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.28) 0, rgba(255,255,255,0) 4px, rgba(0,0,0,0) 60%, rgba(0,0,0,0.3))" }} />
              </div>
              {/* inner and outer ends */}
              <div className="absolute" style={{ left: WALL - TRAY_H, top: WALL, width: TRAY_H, height: trayD, transformOrigin: "100% 50%", transform: "rotateY(90deg)", background: "#8a6a3f" }} />
              <div className="absolute overflow-hidden" style={{ left: W - WALL - TRAY_H, top: WALL, width: TRAY_H, height: trayD, transformOrigin: "100% 50%", transform: "rotateY(90deg)", background: RED }}>
                <div className="absolute inset-0" style={{ background: "linear-gradient(90deg, rgba(255,255,255,0.3) 0, rgba(255,255,255,0.06) 5px, rgba(0,0,0,0) 55%, rgba(0,0,0,0.34))" }} />
                <div className="absolute left-1/2 top-1/2 whitespace-nowrap font-mono text-[9px] font-semibold uppercase tracking-[0.3em] text-[#fbe6c8]" style={{ transform: "translate(-50%,-50%) rotate(-90deg)" }}>
                  {box.number} / pull
                </div>
              </div>
              {/* bed of matches */}
              <div aria-hidden="true" className="absolute overflow-hidden" style={{ left: WALL + 1, top: WALL + 1, width: trayW - 2, height: trayD - 2, transform: `translateZ(${MATCH_Z}px)`, background: "repeating-linear-gradient(180deg, #5d4423 0 9px, #1e140a 9px 13.4px)" }}>
                {matches.map((m, i) => (
                  <div key={i} className="absolute h-[10.5px] rounded-[2px]" style={{ top: m.top, left: 14 + m.shift, width: trayW - 44, transform: `rotate(${m.tilt}deg)`, background: "linear-gradient(180deg, #f8e6bd 0%, #ecd09a 38%, #c9a568 78%, #9a7842 100%)", boxShadow: "0 3px 3px rgba(0,0,0,0.5)" }}>
                    <span className="absolute -right-[10px] -top-[2.5px] h-[15.5px] w-[27px] rounded-[50%]" style={{ background: "radial-gradient(60% 55% at 38% 30%, #ff9c8c 0%, #e2392b 34%, #b01d14 68%, #6d0e09 100%)", boxShadow: "0 2px 3px rgba(0,0,0,0.5)" }} />
                  </div>
                ))}
                {/* the dark inside of the sleeve falling across the matches */}
                <motion.div className="absolute inset-y-0" style={{ left: trayW - WALL - 4, width: 120, x: back, opacity: mouthShade, background: "linear-gradient(90deg, rgba(0,0,0,0.92), rgba(0,0,0,0.5) 22%, rgba(0,0,0,0.12) 60%, rgba(0,0,0,0))" }} />
              </div>
              {/* soft shadow under the print */}
              <motion.div aria-hidden="true" className="absolute bg-black" style={{ left: W - HINGE - CARD_L + 6, top: DP / 2 - CARD_W / 2 + 4, width: CARD_L - 4, height: CARD_W + 4, transform: `translateZ(${MATCH_Z + 1.5}px)`, x: cardShadowX, filter: "blur(9px)", opacity: cardShadow }} />
              {/* the print, hinged on the edge nearest you */}
              <motion.div className="absolute left-0 top-0" style={{ width: 0, height: 0, transformStyle: "preserve-3d", transform: hinge }}>
                <div className="absolute overflow-hidden rounded-[2px] bg-[#f7f4ed]" style={{ left: -CARD_L / 2 - CARD_W / 2, top: -CARD_L / 2, width: CARD_W, height: CARD_L, transform: "rotateZ(-90deg)", boxShadow: "0 0 0 0.5px rgba(0,0,0,0.25)" }}>
                  <div className="absolute overflow-hidden" style={{ left: 9, right: 9, top: 9, bottom: 34, background: box.print.tone }}>
                    <img src={box.print.src} alt={box.print.alt} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: box.print.focus }} />
                  </div>
                  <div className="absolute inset-x-[10px] bottom-[11px] flex items-baseline justify-between font-mono text-[7.5px] uppercase tracking-[0.18em] text-neutral-800">
                    <span className="font-semibold">{box.name}</span>
                    <span className="text-neutral-600">Print 1 of 1</span>
                  </div>
                  <motion.div aria-hidden="true" className="absolute inset-0" style={{ opacity: cardGloss, background: "linear-gradient(118deg, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 68%, rgba(0,0,0,0.18) 100%)" }} />
                </div>
              </motion.div>
            </motion.div>

            {/* the sleeve: long sides with striker strips, the rim of its mouth, and the printed top */}
            <div className="absolute" style={{ left: 0, top: -H, width: W, height: H, transformOrigin: "50% 100%", transform: "rotateX(-90deg)", background: "#b7ad98" }} />
            <div className="absolute overflow-hidden" style={{ left: 0, top: DP - H, width: W, height: H, transformOrigin: "50% 100%", transform: "rotateX(-90deg)", background: PAPER }}>
              <div className="absolute inset-x-0 bottom-[10px] top-[10px]" style={{ background: "#2f1c14", backgroundImage: speckle, backgroundSize: "7px 6px, 5px 7px, 9px 5px", boxShadow: "inset 0 1px 0 rgba(0,0,0,0.6), inset 0 -1px 0 rgba(255,255,255,0.08)" }}>
                {/* scratches where matches have been struck */}
                <div className="absolute left-[18%] top-[30%] h-[2px] w-[90px] rounded-full bg-[#7b5a48]/50" style={{ transform: "rotate(-4deg)" }} />
                <div className="absolute left-[46%] top-[58%] h-[2px] w-[120px] rounded-full bg-[#7b5a48]/40" style={{ transform: "rotate(3deg)" }} />
                <div className="absolute left-[30%] top-[70%] h-px w-[70px] rounded-full bg-[#8a6a58]/50" style={{ transform: "rotate(-2deg)" }} />
              </div>
              <div className="absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.3) 0, rgba(255,255,255,0) 5px, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.16))" }} />
            </div>
            <div aria-hidden="true" className="absolute" style={{ left: W - H, top: 0, width: H, height: DP, transformOrigin: "100% 50%", transform: "rotateY(90deg)", borderStyle: "solid", borderColor: "#d8cdb4", borderWidth: "3px 3px 3px 0", boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.25)" }} />
            <button
              type="button"
              onClick={() => {
                state.current.touched = true;
                slide(!open);
              }}
              aria-expanded={open}
              aria-label={open ? `Slide the ${box.name} matchbox shut` : `Slide the ${box.name} matchbox open`}
              className="absolute left-0 top-0 block cursor-pointer overflow-hidden outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-white"
              style={{ width: W, height: DP, transform: `translateZ(${H}px)`, background: PAPER }}
            >
              <span className="absolute overflow-hidden" style={{ left: 12, top: 12, right: 12, bottom: 44, background: box.cover.tone }}>
                <img src={box.cover.src} alt={box.cover.alt} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: box.cover.focus }} />
                <span className="absolute left-[12px] top-[10px] text-left text-[26px] font-black uppercase leading-[0.9] tracking-[-0.01em] text-[#fff6df]" style={{ textShadow: "0 1px 0 rgba(120,20,10,0.55)" }}>
                  {box.name}
                </span>
                <span className="absolute right-[10px] top-[10px] grid h-[38px] w-[38px] place-items-center rounded-full font-mono text-[9px] font-bold uppercase leading-none tracking-[0.06em] text-[#fff6df]" style={{ background: RED, boxShadow: "0 0 0 2px #fff6df inset" }}>
                  {box.number}
                </span>
              </span>
              <span className="absolute inset-x-[13px] bottom-[14px] flex items-center justify-between font-mono text-[8.5px] font-semibold uppercase tracking-[0.2em]" style={{ color: RED }}>
                <span>{box.line}</span>
                <span>Strike on box</span>
              </span>
              <span aria-hidden="true" className="absolute inset-[5px]" style={{ border: `1.5px solid ${RED}` }} />
              {/* paper sheen and the soft crease along each fold */}
              <span aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(124deg, rgba(255,255,255,0.34) 0%, rgba(255,255,255,0.05) 26%, rgba(255,255,255,0) 46%, rgba(0,0,0,0) 70%, rgba(0,0,0,0.16) 100%)" }} />
              <span aria-hidden="true" className="absolute inset-0" style={{ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.4), inset 0 -3px 5px rgba(0,0,0,0.12), inset 0 3px 5px rgba(255,255,255,0.25)" }} />
            </button>
          </motion.div>
        </div>

        <p className="absolute bottom-5 left-5 font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-emerald-100/80 sm:bottom-8 sm:left-8">
          <span className="text-white">
            {box.name} {box.number}
          </span>
          <span className="block">{open ? "Press the lid to slide it shut" : "Press the lid or drag the drawer"}</span>
        </p>
      </div>
    </section>
  );
}
```
