# Pattern library

Section-by-section landing page patterns - navigation, hero, features, social proof, CTA, footer, and scroll motion - with the recommended default of each in full.

Proven structures for each landing page section. Pick sections within the [composition budget](/design/overview#the-composition-budget), choose **after** committing to a [Direction](/design/direction), then adapt each pattern's content and visual tokens to serve it. The pattern is the structure; your Direction is the soul.

Labels such as `N1`, `H1`, and `F1` are stable identifiers — use them when discussing or reviewing a composition. Snippets assume placement in `src/pages/index.tsx`; adjust relative imports if you extract components.

## How patterns integrate with the scaffold

* **Clean primitives** you can import from `src/components/landing/primitives.tsx`: `Typewriter`, `ScrollReveal`, `StaggerContainer`, `staggerChild`, `AnimatedStat`, `cn`, `motion`, `AnimatePresence`, `useInView`, `ChevronDown`.
* `GlassCard`, `PlaceholderImage`, `BrowserMockup`, and `SectionHeading` contain known [gate](/design/anti-ai-gate) violations. Prefer inline semantic surfaces, or repair `primitives.tsx`.
* **CTA routing:** CTAs navigate to `/home`, which is public in the scaffold. Target a route under `(app)/(protected)/` when sign-in should be required. No "landing seen" storage flag exists.

## Universal rules

* Use semantic tokens; replace every `TODO`; use icons or inline SVG rather than pictograph emoji.
* Wrap the tree in `<MotionConfig reducedMotion="user">`; manually gate scroll transforms, timer loops, and CSS keyframes ([rule 13](/design/overview#hard-rules-non-negotiable)).
* Run the [anti-AI gate](/design/anti-ai-gate) after composition.
* After you compose, eyeball-check that the page serves its Direction. If it does not, revise the Direction or the composition rather than adding more patterns.

## Navigation — 6 patterns, pick one (or none)

If your landing lives under `(app)/`, apply the [nav-hiding patch](/design/overview#hide-the-global-navigation-on-the-landing-route) **before** dropping in any pattern below — otherwise the app's global `<Navigation />` stacks on top of the landing chrome, the clearest telltale of a bolted-on landing. A static top-level `src/pages/index.tsx` sits outside that layout and inherits no app chrome.

### N1 — Dual-state floating pill (recommended default)

When to use: most modern SaaS, productivity tools, consumer products. The default workhorse — pick this unless your direction calls for something specific. Three coordinated pieces: a static top nav at page top, a floating pill that materializes on scroll, and an animated mobile dropdown. Active section highlighting works in both desktop states.

**Direction → choice:** the "pill materializes on scroll" pattern reads as polish-conscious and modern. If your direction is editorial/zine (no polish theater) or brutalism (rejects smooth transitions), pick N5 or N3 instead.

Full source - N1 dual-state floating pill

```tsx
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import {
  AnimatePresence,
  motion,
  cn,
} from '../components/landing/primitives'

const NAV_SECTIONS = [
  { id: 'features', label: 'Features' },
  { id: 'pricing', label: 'Pricing' },
  { id: 'faq', label: 'FAQ' },
] as const

const APP_NAME = 'TODO: Brand'

// Small inline useActiveSection — the scaffolded LandingPage.tsx defines
// one but doesn't export it. Reads from the document scroll, not a custom
// scroll root, so element rects use viewport coords directly.
function useActiveSection(ids: readonly string[]) {
  const [active, setActive] = useState<string | null>(null)
  useEffect(() => {
    const calc = () => {
      const triggerY = window.innerHeight * 0.3
      let cur: string | null = null
      for (const id of ids) {
        const el = document.getElementById(id)
        if (el && el.getBoundingClientRect().top <= triggerY) cur = id
      }
      setActive(cur)
    }
    calc()
    window.addEventListener('scroll', calc, { passive: true })
    return () => window.removeEventListener('scroll', calc)
  }, [ids])
  return active
}

export function LandingNav() {
  const [isScrolled, setIsScrolled] = useState(false)
  const [mobileOpen, setMobileOpen] = useState(false)
  const navigate = useNavigate()
  const ids = NAV_SECTIONS.map(s => s.id)
  const active = useActiveSection(ids)

  useEffect(() => {
    const onScroll = () => setIsScrolled(window.scrollY > 80)
    onScroll()
    window.addEventListener('scroll', onScroll, { passive: true })
    return () => window.removeEventListener('scroll', onScroll)
  }, [])

  const scrollTo = (id: string) => {
    setMobileOpen(false)
    document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
  }

  // /home is public by default in the scaffold. To force sign-in on click,
  // either swap to an `(app)/(protected)/<page>` route or open <AuthOverlay> here.
  const enterApp = () => navigate('/home')

  const mobileDropdown = (
    <AnimatePresence>
      {mobileOpen && (
        <motion.div
          initial={{ opacity: 0, y: -8, scale: 0.95 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: -8, scale: 0.95 }}
          transition={{ duration: 0.2 }}
          className="md:hidden mt-2 rounded-2xl overflow-hidden bg-card/95 backdrop-blur-xl border border-border shadow-lg"
        >
          <div className="p-2 flex flex-col gap-0.5">
            {NAV_SECTIONS.map(link => (
              <button
                key={link.id}
                onClick={() => scrollTo(link.id)}
                className={cn(
                  'px-4 py-2.5 rounded-xl text-sm font-medium text-left transition-colors',
                  active === link.id
                    ? 'text-foreground bg-muted'
                    : 'text-muted-foreground hover:text-foreground hover:bg-muted/70',
                )}
              >
                {link.label}
              </button>
            ))}
            <div className="h-px bg-border my-1" />
            <button onClick={enterApp} className="px-4 py-2.5 rounded-xl text-sm font-medium text-left text-primary hover:bg-muted/70">
              Get Started
            </button>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  )

  return (
    <>
      {/* Static top nav (page top; fades out on scroll) */}
      <motion.div
        className="absolute top-0 left-0 right-0 z-50"
        animate={{ opacity: isScrolled ? 0 : 1 }}
        transition={{ duration: 0.3 }}
        style={{ pointerEvents: isScrolled ? 'none' : 'auto' }}
      >
        <div className="max-w-6xl mx-auto px-6 py-5 flex items-center justify-between">
          <span className="font-semibold text-lg tracking-tight text-foreground">{APP_NAME}</span>
          <div className="flex items-center gap-4">
            <div className="hidden md:flex items-center gap-8">
              {NAV_SECTIONS.map(link => (
                <button
                  key={link.id}
                  onClick={() => scrollTo(link.id)}
                  className={cn(
                    'text-sm font-medium transition-colors',
                    active === link.id ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
                  )}
                >
                  {link.label}
                </button>
              ))}
            </div>
            <button
              onClick={enterApp}
              className="hidden md:inline-flex items-center px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 active:scale-[0.97] transition-transform"
            >
              Get Started
            </button>
            <button
              className="md:hidden text-muted-foreground hover:text-foreground"
              onClick={() => setMobileOpen(p => !p)}
              aria-label="Toggle menu"
            >
              {mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
            </button>
          </div>
        </div>
        <div className="max-w-6xl mx-auto px-6">{mobileDropdown}</div>
      </motion.div>

      {/* Floating pill (slides down on scroll) */}
      <AnimatePresence>
        {isScrolled && (
          <motion.nav
            className="fixed top-4 inset-x-0 z-50 flex justify-center pointer-events-none"
            initial={{ y: -80, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{ y: -80, opacity: 0 }}
            transition={{ duration: 0.4, ease: [0.25, 0.4, 0.25, 1] }}
          >
            <div className="pointer-events-auto flex items-center gap-1 px-2 py-1.5 rounded-full bg-background/80 backdrop-blur-2xl border border-border shadow-lg">
              <span className="text-foreground font-semibold text-sm px-3 whitespace-nowrap">{APP_NAME}</span>
              <div className="w-px h-4 bg-border mx-1 hidden md:block" />
              <div className="hidden md:flex items-center gap-0.5">
                {NAV_SECTIONS.map(link => (
                  <button
                    key={link.id}
                    onClick={() => scrollTo(link.id)}
                    className={cn(
                      'px-3.5 py-1.5 rounded-full text-sm font-medium transition-colors',
                      active === link.id
                        ? 'text-foreground bg-muted'
                        : 'text-muted-foreground hover:text-foreground hover:bg-muted/70',
                    )}
                  >
                    {link.label}
                  </button>
                ))}
              </div>
              <button
                onClick={enterApp}
                className="ml-1 px-3.5 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 active:scale-[0.97] transition-transform"
              >
                Get Started
              </button>
            </div>
          </motion.nav>
        )}
      </AnimatePresence>
    </>
  )
}
```

### The other five nav patterns

* **N2 — Sticky docs-style top bar.** Dev tools, doc-heavy products, anything where the nav needs to persist and feel functional rather than decorative. No transformation on scroll — a solid `sticky top-0` bar with backdrop blur, a mono wordmark, a few text links, and a "Launch app →" text button.
* **N3 — Corner brand, no nav at all.** Manifesto sites, single-screen kinetic-typography landings, retro directions. When the page is so committed to a single idea that a nav would diminish it: just two `fixed` corner elements — the brand top-left, an "Enter →" button top-right — each fading in on a delay.
* **N4 — Hamburger-only.** Consumer products with strong identity where the nav is a secondary concern. Fixed brand top-left, a round bordered hamburger button top-right on every device; opening it covers the page with a `backdrop-blur` overlay of oversized centered links that stagger in.
* **N5 — Inline anchor list (editorial).** Magazine, zine, or single-page long-scroll pages where the nav is prose-adjacent. No fixed bar, no pill — a masthead row (italic serif brand, issue label, heavy bottom border) with a small mono-uppercase anchor index beneath it that scrolls with the page.
* **N6 — Hover-panel mega menu.** Product suites with enough surface area that a flat nav wouldn't fit. Each top-level item opens a categorized panel on hover (label + one-line description per entry). **Direction → choice:** only pick N6 if the product actually has 2+ top-level categories. A two-page product using a mega menu looks bigger than it is and reads as try-hard.

## Hero — 5 patterns, pick one

* **H1 — Split-screen with animated product mockup.** Product-led SaaS where a UI preview is the easiest way to explain the thing. Text on one side, a live-rendered React mockup on the other — the mockup is a React component (staggered tiles, styled divs), never an AI-generated image.
* **H2 — Full-bleed atmospheric.** Consumer brands, lifestyle and editorial products, products whose value is mood more than feature. Full source below.
* **H3 — Bento hero.** Multi-feature SaaS where the first viewport should already communicate 3–5 things. A headline tile (spanning 4 columns and 2 rows) anchors the grid; the rest fills with a stat tile, an inline-visual tile, a pull-quote tile, and an inverted info tile — each small and distinct, not three identical cards.
* **H4 — Typographic poster.** Manifesto sites, writing products, agencies. The headline IS the hero: `text-[12vw]` serif type on a near-empty canvas with one accent-colored italic word, closed by a bordered baseline row holding one short supporting sentence and a mono CTA.
* **H5 — Live terminal / CLI demo.** Dev tools, APIs, technical infrastructure. A fake terminal types commands with realistic variable timing — slower keystrokes on input lines with random jitter, near-instant output lines — so it reads as convincing, not scripted.

**The H5 reduced-motion lesson:** the terminal's typing loop is driven by `setTimeout`, which is not framer-motion — `<MotionConfig reducedMotion="user">` does not cover it. The pattern must call `useReducedMotion()` itself and short-circuit, jumping straight to the terminal's end state. The same applies to any timer or `requestAnimationFrame` loop ([rule 13](/design/overview#hard-rules-non-negotiable)).

### H2 — Full-bleed atmospheric (full source)

A generated atmospheric image fills the viewport; the headline floats over a gradient scrim.

**Image-generation workflow:** generate the image with `integration.post('freepik/generate-image-flux-dev', ...)` (or `gemini/generate-image`, `openai/generate-image`). **Your prompt must include `no text, no words, no letters, no writing, no logos`** — AI models hallucinate gibberish text otherwise. Persist the URL with `useR2Files` if you want it stable across renders. Generated images are for atmosphere only — product mockups stay React components.

Full source - H2 full-bleed atmospheric

```tsx
import { useNavigate } from 'react-router-dom'
import { motion } from '../components/landing/primitives'

const HERO_BG = 'TODO: paste integration-generated image URL here'

export function AtmosphericHero() {
  const navigate = useNavigate()
  return (
    <section className="relative min-h-[90vh] overflow-hidden">
      <img src={HERO_BG} alt="" className="absolute inset-0 w-full h-full object-cover" />
      <div className="absolute inset-0 bg-gradient-to-b from-background/40 via-background/30 to-background" />
      <div className="relative z-10 max-w-4xl mx-auto px-6 pt-36 pb-24 text-center">
        <motion.h1
          initial={{ opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.8, delay: 0.2 }}
          className="text-5xl md:text-7xl font-serif italic text-foreground leading-[1.02]"
        >
          TODO: 3–8 word headline.
        </motion.h1>
        <motion.p
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ delay: 0.6, duration: 0.6 }}
          className="mt-6 text-lg text-muted-foreground max-w-xl mx-auto"
        >
          TODO: one sentence.
        </motion.p>
        <motion.button
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ delay: 0.9 }}
          onClick={() => navigate('/home')}
          className="mt-10 inline-flex items-center px-7 py-3.5 rounded-full bg-foreground text-background text-sm font-medium hover:opacity-90"
        >
          Enter →
        </motion.button>
      </div>
    </section>
  )
}
```

## Features — 5 patterns, pick one (sometimes two)

Three identical cards with icon + title + description is the most-common AI-generated layout tell. Every pattern here is designed to break that shape — if your features section renders three of the same thing with the same structure, redesign it.

### F1 — Tabbed interactive showcase (full source)

When to use: 3–5 features, each of which needs a visual. One tab list, one preview area. Clicking a tab swaps the preview.

Full source - F1 tabbed interactive showcase

```tsx
import { useState } from 'react'
import { motion, AnimatePresence, cn } from '../components/landing/primitives'

const FEATURES = [
  { id: 'speed', label: 'Speed', title: 'TODO headline.', body: 'TODO one sentence.' },
  { id: 'sync', label: 'Sync', title: 'TODO headline.', body: 'TODO one sentence.' },
  { id: 'share', label: 'Share', title: 'TODO headline.', body: 'TODO one sentence.' },
]

export function TabbedFeatures() {
  const [active, setActive] = useState(FEATURES[0].id)
  const feature = FEATURES.find(f => f.id === active)!
  return (
    <section id="features" className="max-w-5xl mx-auto px-6 py-24">
      <h2 className="text-3xl md:text-4xl font-bold text-foreground tracking-[-0.02em]">
        TODO: section headline.
      </h2>
      <div className="mt-10 grid md:grid-cols-[220px_1fr] gap-8">
        <ul className="flex md:flex-col gap-1 border-b md:border-b-0 md:border-r border-border md:pr-6">
          {FEATURES.map(f => (
            <li key={f.id}>
              <button
                onClick={() => setActive(f.id)}
                className={cn(
                  'w-full text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors',
                  active === f.id
                    ? 'bg-muted text-foreground'
                    : 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
                )}
              >
                {f.label}
              </button>
            </li>
          ))}
        </ul>
        <AnimatePresence mode="wait">
          <motion.div
            key={feature.id}
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -8 }}
            transition={{ duration: 0.2 }}
            className="rounded-2xl border border-border bg-card p-8 min-h-[260px]"
          >
            <span className="text-xs font-mono uppercase tracking-[0.2em] text-primary">{feature.label}</span>
            <h3 className="mt-2 text-2xl font-semibold text-foreground">{feature.title}</h3>
            <p className="mt-3 text-muted-foreground max-w-md">{feature.body}</p>
            {/* Add a feature-specific inline visual here — styled divs, SVG, not AI images. */}
          </motion.div>
        </AnimatePresence>
      </div>
    </section>
  )
}
```

### The other four feature patterns

* **F2 — Alternating visual rows.** 2–4 features where each deserves space. Rows alternate left/right (`md:flex-row-reverse` on odd rows) so the page has rhythm; each row pairs a `ScrollReveal`-wrapped inline visual with a label + headline + one-paragraph body sliding in from opposite directions.
* **F3 — Bento feature grid.** 4–7 features, several of which fit in smaller tiles. A 6-column grid of `col-span` tiles where **size = hierarchy** — the largest tile is the primary feature.
* **F4 — Single scrolling showcase.** One feature is so much more important than the rest that it deserves the whole section: mono label, one-sentence claim as the headline, a 16:9 inline visual, then three short supporting beats in a row. **Direction → choice:** F4 suits editorial, minimalist, and premium directions where restraint is the aesthetic. Avoid it if your Direction calls for "show the whole product at a glance" — use F3 instead.
* **F5 — Code-block feature list.** Dev tools where each feature is a code snippet. Title + description on one side, a `<pre>` code sample on the other — the code IS the demo.

## Social proof — 4 patterns, pick at most one

**Real proof only.** Use a social-proof section only if you actually have real social proof. Fake logos and fake testimonials are worse than no social proof at all.

* **S1 — Logo row + single big stat.** You have a few real customer/user logos AND one memorable metric. Keep it spare — five logos max, one oversized number with a one-line explanation.
* **S2 — Single pull quote.** You have one great quote from a real person. Weight a single quote with serif typography instead of padding out a 3-quote row.
* **S3 — Metric trio.** Three meaningful numbers that tell a story together, using the scaffolded `AnimatedStat` primitive in a `StaggerContainer`. **Reduced-motion note:** `useCountUp` inside `AnimatedStat` uses `requestAnimationFrame`, and the scaffolded primitive doesn't gate it — if your users include people with vestibular sensitivity, either inline a gated version or accept that numbers count once on entry (usually acceptable).
* **S4 — Marquee carousel.** You have a lot of real logos or testimonials and want to show breadth. **Use only if your direction tolerates continuous motion.** A horizontal infinite scroll (the array doubled so the loop appears continuous) is the most-common offender for reduced-motion regressions — the pattern must gate on `useReducedMotion` and freeze (`x: 0`, no `repeat: Infinity` transition) for those users.

## CTA — 3 patterns, pick one

### C1 — Contrast band (full source)

When to use: the default closer. A full-width band that breaks the page's rhythm and makes the action feel decisive.

Full source - C1 contrast band

```tsx
import { useNavigate } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { ScrollReveal } from '../components/landing/primitives'

export function ContrastBand() {
  const navigate = useNavigate()
  return (
    <section className="bg-primary text-primary-foreground">
      <div className="max-w-4xl mx-auto px-6 py-24 text-center">
        <ScrollReveal>
          <h2 className="text-4xl md:text-5xl font-bold leading-tight tracking-[-0.02em]">
            TODO: one-line close.
          </h2>
          <p className="mt-4 opacity-80 max-w-md mx-auto">TODO: one-line support.</p>
          <button
            onClick={() => navigate('/home')}
            className="mt-8 inline-flex items-center gap-2 px-7 py-3.5 rounded-full bg-background text-foreground text-sm font-medium group"
          >
            TODO: verb
            <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
          </button>
        </ScrollReveal>
      </div>
    </section>
  )
}
```

### The other two CTA patterns

* **C2 — Centered glow.** Subtle close for editorial, minimalist, or premium directions. The page keeps the same background; a soft radial glow (`bg-primary/15 blur-3xl` circle behind the content) gives the CTA weight without a hard color break. Serif italic headline, single button.
* **C3 — Asymmetric full-bleed.** Brutalist, editorial, or agency directions. Breaks the max-width container between heavy `border-y-2` rules: left-anchored giant serif type (`text-[10vw]`), right-anchored mono uppercase button, negative space between.

## Footer — 3 patterns, pick one

### FT2 — Column grid (full source)

When to use: the default SaaS footer. Brand column + 2–4 link columns + a small attribution row.

Full source - FT2 column grid

```tsx
import { Github, Twitter, Mail } from 'lucide-react'

const LINKS = {
  Product: ['Overview', 'Changelog', 'Pricing'],
  Company: ['About', 'Blog', 'Careers'],
  Resources: ['Docs', 'Community', 'Support'],
}
const SOCIALS = [
  { icon: Github, href: 'TODO', label: 'GitHub' },
  { icon: Twitter, href: 'TODO', label: 'Twitter' },
  { icon: Mail, href: 'TODO', label: 'Email' },
]

export function ColumnFooter() {
  return (
    <footer className="border-t border-border">
      <div className="max-w-6xl mx-auto px-6 py-16 grid grid-cols-2 md:grid-cols-5 gap-10">
        <div className="col-span-2">
          <span className="font-semibold text-foreground">TODO: brand</span>
          <p className="mt-2 text-sm text-muted-foreground max-w-xs">TODO: one-line product description.</p>
          <div className="mt-5 flex items-center gap-2">
            {SOCIALS.map(({ icon: Icon, href, label }) => (
              <a
                key={label}
                href={href}
                aria-label={label}
                className="w-9 h-9 grid place-items-center rounded-lg bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors"
              >
                <Icon className="w-4 h-4" />
              </a>
            ))}
          </div>
        </div>
        {Object.entries(LINKS).map(([heading, items]) => (
          <div key={heading}>
            <h4 className="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-4">{heading}</h4>
            <ul className="space-y-2">
              {items.map(i => (
                <li key={i}>
                  <a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{i}</a>
                </li>
              ))}
            </ul>
          </div>
        ))}
      </div>
      <div className="border-t border-border">
        <div className="max-w-6xl mx-auto px-6 py-4 text-xs text-muted-foreground flex justify-between">
          <span>&copy; {new Date().getFullYear()} TODO: brand</span>
          <a href="https://deep.space" className="hover:text-foreground">Built with DeepSpace</a>
        </div>
      </div>
    </footer>
  )
}
```

### The other two footer patterns

* **FT1 — Minimal mono.** Editorial, zine, or manifesto pages where the footer should disappear into the page. One line of mono-uppercase meta-info (brand, typefaces, year — a colophon) and one link, above a heavy top border.
* **FT3 — Editorial masthead.** Magazine/editorial/zine pages. A footer masthead echoing the nav masthead — italic serif brand, issue number and date, "Edited by" credit — closing the "it's a printed issue" metaphor.

## Scroll & motion — 4 patterns, pick zero to N

**Skip this section entirely unless your Design Direction calls for scroll choreography.** A quiet or still direction ships without any of these.

Every pattern here uses `useTransform` from `useScroll` or a continuous animation loop. **Both bypass `<MotionConfig reducedMotion="user">`** — manual `useReducedMotion()` gates are required.

* **SM1 — Parallax background layer.** Editorial or atmospheric directions where a slow layer shift behind content adds depth. Maps `scrollYProgress` to a `-12%` → `12%` background translate; when `useReducedMotion()` is true the range collapses to `0%`/`0%`.
* **SM2 — Pinned section with stage progression.** Product walkthroughs — 3–5 stages advance as the user scrolls through a pinned section (`sticky top-0` inside a container `STAGES.length * 80vh` tall); each stage swaps opacity on the visual. Reduced-motion users see the final stage immediately.
* **SM3 — Scroll progress indicator.** Long-form editorial pages where the user wants to know how far in they are. A thin fixed bar at the top of the viewport, `scaleX` driven by `scrollYProgress` with `origin-left`.
* **SM4 — Word-by-word reveal heading.** Manifesto sites, writing products, kinetic-typography directions. The headline splits into words; each fades in as it enters the viewport (`useInView`, once). Gated: reduced-motion users get `duration: 0` and see the whole heading at once.

## Next steps

* [Anti-AI gate](/design/anti-ai-gate) — run the full gate after composing.
* [Worked examples](/design/worked-examples) — see the patterns adapted to five committed directions.
