# Pop-Up Card Where a Typed Sentence Stands Up as a Layered Paper Homepage

> Pop-up card in CSS 3D: a typed sentence lies on the table and a paper homepage stands up from the fold in die-cut layers. Free React and HTML code to copy.

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/pop-up-card-where-a-typed-sentence-stands-up-as-a-layered-paper-homepage).

- Page, with a live preview: https://shadcn.exchange/r/weinc/pop-up-card-where-a-typed-sentence-stands-up-as-a-layered-paper-homepage
- Screenshot: https://shadcn.exchange/shot/weinc/pop-up-card-where-a-typed-sentence-stands-up-as-a-layered-paper-homepage/light.png
- Registry item: https://we.inc/r/pop-up-card-where-a-typed-sentence-stands-up-as-a-layered-paper-homepage.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/pop-up-card-where-a-typed-sentence-stands-up-as-a-layered-paper-homepage.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/pop-up-card-where-a-typed-sentence-stands-up-as-a-layered-paper-homepage.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useReducedMotion } from "framer-motion";

/** Everything printed on the card. Change the business here. */
const site = {
  sentence: "A sourdough bakery in Lisbon that takes orders for Saturday pickup.",
  brand: "Forno Lume",
  links: ["Bread", "Mornings", "Find us"],
  headline: ["Bread worth", "waking up", "for."],
  sub: "Slow dough, shaped by hand in Alfama.",
  cta: "Order for Saturday",
  note: ["Saturday pickup", "8 until 1"],
  hero: { src: "https://images.pexels.com/photos/4881596/pexels-photo-4881596.jpeg?auto=compress&cs=tinysrgb&w=600", alt: "Round sourdough loaf on a dark surface dusted with flour" },
  tiles: [
    { label: "Country loaf", src: "https://images.pexels.com/photos/4881589/pexels-photo-4881589.jpeg?auto=compress&cs=tinysrgb&w=300", alt: "Sliced sourdough seen from above on a floured black table", tone: "#2a2420" },
    { label: "Almond croissant", src: "https://images.pexels.com/photos/13439695/pexels-photo-13439695.jpeg?auto=compress&cs=tinysrgb&w=300", alt: "Two almond croissants on white paper", tone: "#d9d2c8" },
    { label: "Seeded rye", src: "https://images.pexels.com/photos/17890200/pexels-photo-17890200.jpeg?auto=compress&cs=tinysrgb&w=300", alt: "Dark crusted loaf cut open on a cloth", tone: "#3a2c22" },
  ],
};

const BLUE = "#4F98FB";
const INK = "#0B0D12";
const PAPER = "#F8F6F1";
// Design pixels. The whole scene is scaled to fit its frame.
const W = 580; // card width
const H = 360; // height of the leaf that stands up
const D = 330; // depth of the leaf that lies on the table
const SCENE_W = 900;
const SCENE_H = 740;
const OPEN = 97; // degrees between the two leaves when the card is standing
const FLAT = 7;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const stand = "rotateX(calc(var(--a) * 1deg - 90deg))";

/** A die-cut piece that stands parallel to the back leaf, `d` pixels in front of the fold, held by paper tabs. */
function Flat({ x, w, h, d, radius, tabs, edge = "#cfcabf", children }: { x: number; w: number; h: number; d: number; radius: string; tabs: number[]; edge?: string; children: ReactNode }) {
  return (
    <div className="absolute" style={{ left: x - W / 2, top: -h, width: w, height: h, transformOrigin: "50% 100%", transform: `translateZ(${d}px) ${stand}`, transformStyle: "preserve-3d" }}>
      {tabs.map((tx) => (
        <div
          key={tx}
          aria-hidden="true"
          className="absolute"
          style={{ left: tx, top: 10, width: 20, height: d, transformOrigin: "50% 0", transform: "rotateX(calc(var(--a) * -1deg))", background: "linear-gradient(180deg, #e9e5dc 0%, #d2cdc2 100%)", boxShadow: "inset 1px 0 0 rgba(255,255,255,0.7), inset -1px 0 0 rgba(0,0,0,0.12)" }}
        />
      ))}
      <div aria-hidden="true" className="absolute inset-0" style={{ borderRadius: radius, background: edge, transform: "translateZ(-2.5px)" }} />
      <div aria-hidden="true" className="absolute inset-0" style={{ borderRadius: radius, background: edge, transform: "translateZ(-1.2px)" }} />
      <div className="absolute inset-0 overflow-hidden" style={{ borderRadius: radius, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.75), inset 0 -1px 0 rgba(0,0,0,0.14)" }}>
        {children}
        <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0) 30%, rgba(20,14,4,0.10) 100%)" }} />
      </div>
    </div>
  );
}

/** The soft shadow a standing piece throws on the leaf behind it. */
function Cast({ x, w, h, d, radius }: { x: number; w: number; h: number; d: number; radius: string }) {
  return (
    <div
      aria-hidden="true"
      className="absolute"
      style={{ left: x + d * 0.3, top: H - h + d * 0.2, width: w, height: h + 30, borderRadius: radius, background: "rgba(38,28,10,0.34)", filter: `blur(${(5 + d * 0.09).toFixed(1)}px)`, opacity: "var(--s)" as unknown as number }}
    />
  );
}

/** The shadow at the foot of a standing piece, lying on the base leaf. */
function Foot({ x, w, d }: { x: number; w: number; d: number }) {
  return (
    <div aria-hidden="true" className="absolute" style={{ left: x + 6, top: d - 26, width: w + 16, height: 30, borderRadius: 12, background: "linear-gradient(180deg, rgba(38,28,10,0), rgba(38,28,10,0.38))", filter: "blur(5px)", opacity: "var(--s)" as unknown as number }} />
  );
}

export default function PopUpHomepageCard() {
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const typedRef = useRef<HTMLSpanElement>(null);
  const [standing, setStanding] = useState(true);
  const sim = useRef({ a: 58, va: 0, target: OPEN, yaw: -10, tyaw: 0, dragging: false, y0: 0, a0: 0, moved: 0, scale: 1, nextDip: 0, held: false, user: false });

  useEffect(() => {
    const wrap = wrapRef.current;
    const scene = sceneRef.current;
    const card = cardRef.current;
    if (!wrap || !scene || !card) return;
    const s = sim.current;
    let raf = 0;
    let visible = true;
    let last = performance.now();
    const t0 = last;
    s.nextDip = last + 5200;
    if (reduce) s.a = s.target;

    const measure = () => {
      const sc = clamp(wrap.clientWidth / 770, 0.4, 1);
      s.scale = sc;
      wrap.style.height = `${Math.round(SCENE_H * sc)}px`;
      scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(wrap);

    const paint = (now: number) => {
      const idle = reduce ? 0 : Math.sin((now - t0) / 2600) * 5;
      s.yaw += (-10 + idle + s.tyaw - s.yaw) * 0.08;
      card.style.setProperty("--a", s.a.toFixed(2));
      card.style.setProperty("--s", clamp((s.a - 30) / 55, 0, 1).toFixed(3));
      card.style.setProperty("--dim", clamp((70 - s.a) / 90, 0, 0.55).toFixed(3));
      card.style.transform = `rotateX(-27deg) rotateY(${(reduce ? -10 : s.yaw).toFixed(2)}deg)`;
      const t = typedRef.current;
      if (t) {
        const n = Math.round(site.sentence.length * clamp((s.a - 20) / 52, 0, 1));
        if (t.dataset.n !== String(n)) {
          t.dataset.n = String(n);
          t.textContent = site.sentence.slice(0, n);
        }
      }
    };

    const tick = (now: number) => {
      const dt = Math.min(0.033, (now - last) / 1000);
      last = now;
      if (reduce) {
        s.a = s.target;
      } else if (!s.dragging) {
        const acc = 58 * (s.target - s.a) - 8.2 * s.va;
        s.va += acc * dt;
        s.a = clamp(s.a + s.va * dt, 2, 112);
        // the idle demo: press the card down, let the sentence clear, and let it stand again
        if (!s.user && !s.held && now > s.nextDip) {
          if (s.target === OPEN) {
            s.target = 16;
            s.nextDip = now + 1500;
          } else {
            s.target = OPEN;
            s.nextDip = now + 6200;
          }
        }
      }
      paint(now);
      if (visible) raf = requestAnimationFrame(tick);
    };

    const io = new IntersectionObserver(([e]) => {
      const was = visible;
      visible = e.isIntersecting;
      if (visible && !was) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    });
    io.observe(wrap);
    raf = requestAnimationFrame(tick);

    const down = (e: PointerEvent) => {
      if (e.button !== 0 || (e.target as HTMLElement).closest("button")) return;
      s.dragging = true;
      s.y0 = e.clientY;
      s.a0 = s.a;
      s.moved = 0;
      s.va = 0;
      scene.setPointerCapture(e.pointerId);
    };
    const move = (e: PointerEvent) => {
      const r = wrap.getBoundingClientRect();
      s.tyaw = clamp(((e.clientX - r.left) / r.width - 0.5) * 2, -1, 1) * 12;
      if (!s.dragging) return;
      const dy = (s.y0 - e.clientY) / s.scale;
      s.moved = Math.max(s.moved, Math.abs(dy));
      const next = clamp(s.a0 + dy * 0.42, 2, 110);
      s.va = (next - s.a) * 30;
      s.a = next;
    };
    const up = () => {
      if (!s.dragging) return;
      s.dragging = false;
      const open = s.moved < 5 ? s.target !== OPEN : s.a + s.va * 0.12 > 46;
      s.target = open ? OPEN : FLAT;
      s.user = !open;
      s.nextDip = performance.now() + 9000;
      setStanding(open);
    };
    const leave = () => {
      s.tyaw = 0;
    };
    scene.addEventListener("pointerdown", down);
    scene.addEventListener("pointermove", move);
    scene.addEventListener("pointerup", up);
    scene.addEventListener("pointercancel", up);
    scene.addEventListener("pointerleave", leave);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      scene.removeEventListener("pointerdown", down);
      scene.removeEventListener("pointermove", move);
      scene.removeEventListener("pointerup", up);
      scene.removeEventListener("pointercancel", up);
      scene.removeEventListener("pointerleave", leave);
    };
  }, [reduce]);

  const toggle = () => {
    const s = sim.current;
    const open = s.target !== OPEN;
    s.target = open ? OPEN : FLAT;
    s.user = !open;
    s.nextDip = performance.now() + 9000;
    setStanding(open);
  };
  const hold = (v: boolean) => () => {
    sim.current.held = v;
    if (!v) sim.current.nextDip = performance.now() + 4000;
  };

  const grain: CSSProperties = { backgroundImage: "repeating-linear-gradient(92deg, rgba(120,100,60,0.018) 0px, rgba(120,100,60,0) 3px, rgba(120,100,60,0.014) 7px), repeating-linear-gradient(1deg, rgba(120,100,60,0.014) 0px, rgba(120,100,60,0) 4px)" };
  const arch = "104px 104px 4px 4px";
  const sans = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';

  return (
    <section className="relative overflow-hidden bg-[#0B0D12]" style={{ fontFamily: sans }} onMouseEnter={hold(true)} onMouseLeave={hold(false)}>
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(58% 60% at 50% 46%, #2b2f3a 0%, #191c25 42%, #0e1016 74%, #0B0D12 100%)" }} />
      <div ref={wrapRef} className="relative mx-auto w-full overflow-hidden" style={{ height: SCENE_H }}>
        <div
          ref={sceneRef}
          className="absolute left-1/2 top-0 touch-pan-x select-none"
          style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "1500px", perspectiveOrigin: "50% 38%", cursor: "grab" }}
        >
          <div ref={cardRef} className="absolute" style={{ left: SCENE_W / 2, top: 404, width: 0, height: 0, transformStyle: "preserve-3d", transform: "rotateX(-27deg) rotateY(-10deg)", ["--a" as string]: reduce ? OPEN : 58, ["--s" as string]: 1, ["--dim" as string]: 0 }}>
            {/* shadow of the whole card on the table */}
            <div aria-hidden="true" className="absolute" style={{ left: -W / 2 - 30, top: 0, width: W + 150, height: D + 70, transformOrigin: "50% 0", transform: "translate3d(0,4px,-70px) rotateX(90deg)", background: "rgba(0,0,0,0.75)", filter: "blur(34px)" }} />
            <div aria-hidden="true" className="absolute" style={{ left: -W / 2 + 2, top: 0, width: W + 8, height: D + 6, transformOrigin: "50% 0", transform: "translate3d(0,2.5px,0) rotateX(90deg)", background: "rgba(0,0,0,0.8)", filter: "blur(7px)" }} />

            {/* the leaf that lies on the table: it carries the sentence */}
            <div className="absolute overflow-hidden" style={{ left: -W / 2, top: 0, width: W, height: D, transformOrigin: "50% 0", transform: "rotateX(90deg)", background: PAPER, borderRadius: "0 0 10px 10px", ...grain }}>
              <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(38,28,10,0.30) 0%, rgba(38,28,10,0.10) 9%, rgba(38,28,10,0) 30%, rgba(255,255,255,0.35) 100%)" }} />
              <Foot x={338} w={208} d={52} />
              <Foot x={28} w={282} d={96} />
              <Foot x={336} w={212} d={150} />
              <p className="absolute left-[30px] right-[30px] font-mono text-[10px] font-semibold uppercase tracking-[0.3em]" style={{ top: 192, color: BLUE }}>
                One sentence
              </p>
              <p className="absolute left-[28px] right-[40px] text-[31px] font-extrabold leading-[1.12] tracking-[-0.035em]" style={{ top: 212, color: INK }}>
                <span className="sr-only">{site.sentence}</span>
                <span aria-hidden="true">
                  <span ref={typedRef}>{site.sentence}</span>
                  <span className="pop-up-card-sentence-caret ml-[3px] inline-block h-[28px] w-[4px] translate-y-[4px] rounded-full" style={{ background: BLUE }} />
                </span>
              </p>
              <div aria-hidden="true" className="absolute inset-0" style={{ background: "rgba(8,9,14,1)", opacity: "var(--dim)" as unknown as number }} />
            </div>

            {/* the leaf that stands up: the page itself */}
            <div className="absolute" style={{ left: -W / 2, top: -H, width: W, height: H, transformOrigin: "50% 100%", transform: stand, transformStyle: "preserve-3d" }}>
              <div className="absolute inset-0 overflow-hidden" style={{ background: INK, borderRadius: "10px 10px 0 0", transform: "rotateY(180deg) translateZ(1px)", backfaceVisibility: "hidden" }} aria-hidden="true">
                <div className="absolute inset-0" style={{ background: "linear-gradient(165deg, #39404f 0%, #232834 45%, #171b24 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.10), inset 0 -2px 0 rgba(255,255,255,0.16)" }} />
                <span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ background: BLUE, boxShadow: `0 0 24px ${BLUE}` }} />
              </div>
              <div className="absolute inset-0 overflow-hidden" style={{ background: PAPER, borderRadius: "10px 10px 0 0", backfaceVisibility: "hidden", ...grain }}>
                <Cast x={338} w={208} h={276} d={52} radius={arch} />
                <Cast x={28} w={282} h={104} d={96} radius="8px" />
                <div className="absolute left-[28px] right-[28px] top-[20px] flex items-center justify-between">
                  <span className="flex items-center gap-2 text-[13px] font-extrabold tracking-[-0.02em]" style={{ color: INK }}>
                    <span aria-hidden="true" className="h-[9px] w-[9px] rounded-full" style={{ background: BLUE }} />
                    {site.brand}
                  </span>
                  <span className="flex items-center gap-[18px] text-[10.5px] font-semibold text-neutral-600">
                    {site.links.map((l) => (
                      <span key={l}>{l}</span>
                    ))}
                  </span>
                </div>
                <div aria-hidden="true" className="absolute left-[28px] right-[28px] top-[50px] h-px bg-neutral-900/10" />
                <h2 className="absolute left-[28px] top-[74px] text-[50px] font-black leading-[0.94] tracking-[-0.055em]" style={{ color: INK }}>
                  {site.headline.map((l) => (
                    <span key={l} className="block">
                      {l}
                    </span>
                  ))}
                </h2>
                <p className="absolute left-[30px] top-[226px] w-[250px] text-[12.5px] font-semibold leading-snug text-neutral-600">
                  {site.sub} <span style={{ color: "#0B0D12" }}>{site.note[0]}, {site.note[1]}.</span>
                </p>
                <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 35%, rgba(38,28,10,0.16) 100%)" }} />
                <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "rgba(8,9,14,1)", opacity: "var(--dim)" as unknown as number }} />
              </div>
            </div>

            {/* the pieces that stand in front of the page, back to front */}
            <Flat x={338} w={208} h={276} d={52} radius={arch} tabs={[94]}>
              <div className="absolute inset-0 bg-[#fbfaf7]" />
              <div className="absolute inset-[9px] overflow-hidden bg-[#15171c]" style={{ borderRadius: "96px 96px 2px 2px" }}>
                <img src={site.hero.src} alt={site.hero.alt} width={190} height={258} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: "50% 62%" }} />
              </div>
            </Flat>

            <Flat x={28} w={282} h={104} d={96} radius="8px" tabs={[38, 224]}>
              <div className="absolute inset-0 bg-[#fbfaf7]" />
              <ul className="absolute inset-[7px] grid grid-cols-3 gap-[7px]">
                {site.tiles.map((t) => (
                  <li key={t.label} className="relative overflow-hidden rounded-[4px]" style={{ background: t.tone }}>
                    <img src={t.src} alt={t.alt} width={85} height={98} draggable={false} className="h-full w-full object-cover" />
                    <span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-black/0 px-[6px] pb-[6px] pt-5 text-[8.5px] font-bold leading-none text-white">{t.label}</span>
                  </li>
                ))}
              </ul>
            </Flat>

            <Flat x={336} w={212} h={54} d={150} radius="27px" tabs={[96]} edge="#2f69c0">
              <div className="absolute inset-0" style={{ background: `linear-gradient(180deg, #6aa9fc 0%, ${BLUE} 45%, #3f86ea 100%)` }} />
              <span className="absolute inset-0 flex items-center justify-center gap-2 text-[15px] font-extrabold tracking-[-0.02em]" style={{ color: INK }}>
                {site.cta}
                <svg aria-hidden="true" width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M4 10h12M11 5l5 5-5 5" />
                </svg>
              </span>
            </Flat>
          </div>
        </div>

        <div className="absolute inset-x-0 bottom-5 flex justify-center sm:bottom-8">
          <button
            type="button"
            onClick={toggle}
            aria-pressed={standing}
            className="inline-flex h-11 items-center gap-2 rounded-full border border-white/15 bg-white/[0.06] px-5 text-[13px] font-bold tracking-[-0.01em] text-white transition hover:bg-white/[0.14] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4F98FB]"
          >
            <span aria-hidden="true" className="h-2 w-2 rounded-full" style={{ background: standing ? BLUE : "rgba(255,255,255,0.4)" }} />
            {standing ? "Press it flat" : "Stand it up"}
          </button>
        </div>
      </div>
      <style>{`
        @keyframes pop-up-card-sentence-caret-blink { 0%, 55% { opacity: 1 } 56%, 100% { opacity: 0 } }
        .pop-up-card-sentence-caret { animation: pop-up-card-sentence-caret-blink 1.1s steps(1) infinite; }
        @media (prefers-reduced-motion: reduce) { .pop-up-card-sentence-caret { animation: none; } }
      `}</style>
    </section>
  );
}
```
