Editorial Vision Card Stack Landing

A single-page landing with five tilted, stacked vision cards that double as scroll-spy navigation for long-form sections below.

  • landing
  • editorial
  • tanstack-start
  • animation
PROMPT — Editorial Vision Card Stack Landing

Build a single-page vision site for a product. The hero is a stack of five tilted, overlapping editorial cards (one per section). Cards act as nav: click one, the page smooth-scrolls to its long-form section; as the user scrolls, the matching card lifts and straightens. The aesthetic is warm editorial print — cream paper, ink type, serif display, mono body, four saturated accent colors, repeating CSS patterns instead of imagery. Imperative, confident, no marketing fluff.

Stack
- TanStack Start v1 + React 19 (file-based routing in `src/routes/`)
- Tailwind CSS v4 via `@import "tailwindcss"` in `src/index.css`
- Fonts: Instrument Serif (display), IBM Plex Mono (body), loaded from Google Fonts in `index.html`
- lucide-react (`Copy`, `Check`, `ArrowUpRight`, `ExternalLink`)
- sonner for toasts, mounted once in `src/routes/__root.tsx`
- @tanstack/react-query for any async data

Design tokens (src/index.css)
Define on `:root` (light) and `.dark`:

Light:
--background: 48 38% 96%;
--foreground: 30 15% 18%;
--card: 48 30% 97%;
--card-foreground: 30 15% 18%;
--primary: 30 15% 18%;
--primary-foreground: 48 38% 96%;
--muted: 42 22% 90%;
--muted-foreground: 30 8% 42%;
--border: 38 18% 82%;
--input: 38 18% 82%;
--ring: 30 8% 42%;
--radius: 0.5rem;

/* accent cards — raw HSL triplets */
--card-orange: 18 85% 55%;
--card-cream: 40 60% 82%;
--card-blue: 220 70% 60%;
--card-green: 145 55% 60%;
--card-ink: 30 10% 12%;

Dark:
--background: 30 10% 12%;
--foreground: 48 30% 95%;
--card: 30 8% 16%;
--card-foreground: 48 30% 95%;
--primary: 48 30% 95%;
--primary-foreground: 30 10% 12%;
--muted: 30 6% 22%;
--muted-foreground: 36 8% 68%;
--border: 0 0% 100% / 0.10;
--input: 0 0% 100% / 0.15;
--ring: 30 8% 50%;

In `tailwind.config.ts` extend `theme.colors` with `cardOrange/cardCream/cardBlue/cardGreen/cardInk` mapped via `hsl(var(--card-orange))` etc., and `fontFamily.serif = ['"Instrument Serif"', 'Georgia', 'serif']`, `fontFamily.mono = ['"IBM Plex Mono"', 'ui-monospace', 'monospace']`. Set `body { font-family: theme(fontFamily.mono); }` and `h1,h2,h3 { font-family: theme(fontFamily.serif); font-weight: 400; letter-spacing: -0.01em; }`.

Layout (/ page — `src/routes/index.tsx`)
1. Sticky top bar: `h-14 px-6 flex items-center justify-between border-b border-border bg-background/80 backdrop-blur`. Left: 28px logo + wordmark in serif. Right: two external links, both `text-sm text-muted-foreground hover:text-foreground`.
2. Hero: `relative max-w-6xl mx-auto px-6 pt-16 pb-32 md:pt-24 md:pb-48`. Serif `h1` at `text-5xl md:text-7xl leading-[1.05]` with the product's tagline. Sub-headline in mono at `text-sm md:text-base text-muted-foreground max-w-xl mt-6`.
3. `<CardStack />` — `relative h-[420px] md:h-[520px] mt-12` containing 5 absolutely-positioned cards (see below). Below the stack on mobile, render the same labels as a horizontal scroll row of mono buttons for fallback.
4. Five long-form sections, each `id="section-1|section-2|section-3|section-4|section-5"`, `min-h-screen max-w-3xl mx-auto px-6 py-24 md:py-32`. Each opens with a serif `h2 text-4xl md:text-6xl`, then mono prose at `text-base leading-[1.7] text-muted-foreground space-y-6`. Sections cover whatever five themes the product needs (e.g. Discover, Markets, Token, Roadmap, Vision). One section may include a stat grid plus a copy-to-clipboard row using `<CopyBtn />`.
5. Footer: `mt-32 border-t border-border py-10 px-6 text-xs text-muted-foreground flex justify-between` — copyright left, `/privacy` and `/terms` links right.
6. Extra routes: `src/routes/privacy.tsx`, `src/routes/terms.tsx` (centered `max-w-2xl mx-auto px-6 py-24` prose), and root `notFoundComponent` for 404.

CardStack (the key piece)
File: `src/components/CardStack.tsx`. Behavior:

- DOM: a `<div role="tablist">` containing 5 `<button role="tab">` elements. Each card is `absolute left-1/2 top-1/2 w-[260px] h-[340px] md:w-[300px] md:h-[400px] -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 flex flex-col justify-between text-left card-tilt overflow-hidden`.
- Inline `style` per card composes the resting transform: `transform: translate(calc(-50% + ${tx}px), calc(-50% + ${ty}px)) rotate(${rotateDeg}deg)`, plus `backgroundColor: hsl(var(--card-${color}))`, `color: hsl(var(--card-${color}-fg))`, `zIndex: z`.
- Inside each card: top row = mono kicker `text-xs uppercase tracking-[0.18em] opacity-70`; bottom = serif title `text-4xl md:text-5xl`. A patterned div fills the middle band: `<div className={`flex-1 my-4 ${pattern}`} aria-hidden />` using `currentColor` so the pattern inherits the card's foreground.
- Card definitions (keep this exact order and z-stack so the headline card sits centered on top):

  ```ts
  const cards = [
    { id: 'section-1', title: 'One',   kicker: 'Kicker one',   color: 'orange', pattern: 'pattern-lines',  rotateDeg: -10, tx:  8, ty: 24, z: 10 },
    { id: 'section-2', title: 'Two',   kicker: 'Kicker two',   color: 'cream',  pattern: 'pattern-dots',   rotateDeg:   6, tx: -16, ty: 40, z: 20 },
    { id: 'section-3', title: 'Three', kicker: 'Kicker three', color: 'blue',   pattern: 'pattern-waves',  rotateDeg:  -4, tx:   0, ty:  0, z: 30 },
    { id: 'section-4', title: 'Four',  kicker: 'Kicker four',  color: 'green',  pattern: 'pattern-grid',   rotateDeg:   8, tx:  -8, ty: 32, z: 20 },
    { id: 'section-5', title: 'Five',  kicker: 'Kicker five',  color: 'ink',    pattern: 'pattern-frames', rotateDeg: -12, tx:  16, ty: 56, z: 10 },
  ] as const;
  ```

- State machine: a single `const [active, setActive] = useState('section-1')`. Click handler:
  1. `setActive(id)`
  2. `requestAnimationFrame(() => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }))`
- Scroll-spy: on mount, create one `IntersectionObserver` with `rootMargin: '-40% 0px -55% 0px'`, `threshold: 0`. For each `<section>` in the page, on `isIntersecting` call `setActive(id)`. Clean up in the effect's return.
- Active state visuals: when `data-state="active"`, override transform via CSS to `translate(-50%, -52%) rotate(0deg) scale(1.04)` and bump `z-index` to 40 so the active card lifts to center, flattens, and overlaps siblings.
- Constants:
  - `const STACK_LIFT_PX = 8;`
  - `const ACTIVE_SCALE = 1.04;`
  - `const TRANSITION_MS = 420;`
- Accessibility: each button gets `aria-controls={id}` and `aria-selected={active===id}`. Sections get `tabIndex={-1}` and `aria-labelledby` pointing to their `h2`. Visible focus ring: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background`. Wrap all transforms in `@media (prefers-reduced-motion: reduce) { .card-tilt { transition: none; transform: none !important; } }`.

CopyBtn (used wherever an address, ID, or token needs copying)
File: `src/components/CopyBtn.tsx`. Renders a `<button>` with the `Copy` icon; on click, `await navigator.clipboard.writeText(value)`, then `setCopied(true)`, swap icon to `Check`, fire `toast.success('Copied')` from sonner, reset after `2000ms`. Mount `<Toaster position="bottom-right" />` once in `src/routes/__root.tsx`.

Component-specific CSS (append to `src/index.css`):

```css
@layer utilities {
  .card-tilt {
    transition:
      transform 420ms cubic-bezier(0.22, 0.61, 0.36, 1),
      box-shadow 420ms cubic-bezier(0.22, 0.61, 0.36, 1),
      opacity 260ms ease;
    box-shadow:
      0 1px 2px hsl(var(--foreground) / 0.06),
      0 12px 30px -10px hsl(var(--foreground) / 0.25);
    will-change: transform;
  }
  .card-tilt[data-state="active"] {
    box-shadow:
      0 4px 8px hsl(var(--foreground) / 0.10),
      0 30px 60px -14px hsl(var(--foreground) / 0.38);
  }

  .pattern-lines  { background-image: repeating-linear-gradient(90deg, currentColor 0, currentColor 1.5px, transparent 1.5px, transparent 6px); }
  .pattern-waves  { background-image: repeating-linear-gradient( 0deg, currentColor 0, currentColor 1px,   transparent 1px,   transparent 5px); }
  .pattern-grid   { background-image: linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px); background-size: 10px 10px; }
  .pattern-dots   { background-image: radial-gradient(currentColor 1.2px, transparent 1.2px); background-size: 8px 8px; }
  .pattern-frames { background-image: linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px); background-size: 28px 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .card-tilt { transition: none !important; transform: translate(-50%, -50%) !important; }
}
```

SEO / meta (per-route `head()` in TanStack Start; nothing page-specific in `__root.tsx`)
- `/` title: product name + tagline (≤60 chars).
- `/` description: one-sentence value prop (<160 chars).
- Add `og:title`, `og:description`, `og:url`, `og:type: "website"`, `og:image` and `twitter:image` pointing to a 1200×630 JPG with `og:image:type: image/jpeg`, `og:image:width: 1200`, `og:image:height: 630`.
- Canonical `<link rel="canonical">` on the leaf route only.
- Root `__root.tsx`: `viewport`, `charSet`, `og:site_name`, and a JSON-LD `Organization` block in `scripts`.
- Index route: add a JSON-LD `WebSite` block with `name`, `url`, and `potentialAction` SearchAction.

Hard rules
- Only semantic tokens in components — no `text-white`, `bg-black`, hex, or RGB literals anywhere outside `src/index.css`.
- All colors stored as raw HSL triplets in `:root` / `.dark`, consumed via `hsl(var(--token))` (including the five `--card-*` accents).
- Never edit `src/routeTree.gen.ts` — it is generated by the TanStack Router Vite plugin.
- One `<Toaster />` instance only, mounted in `__root.tsx`.
- No section dividers between long-form sections — rely on whitespace (`py-24 md:py-32`) and the active card lift for orientation.
- No autoplay, no carousels, no parallax — motion is limited to the card transform transition and `scroll-behavior: smooth` on `html`.
- The five `cards` definitions are the single source of truth — nav buttons, stack, and scroll-spy all derive from this array.