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.
On this page
Proven structures for each landing page section. Pick sections within the composition budget, choose after committing to a 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, andSectionHeadingcontain known gate violations. Prefer inline semantic surfaces, or repairprimitives.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). - Run the 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)#
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
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-0bar 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
fixedcorner 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-bluroverlay 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.
H2 — Full-bleed atmospheric (full source)#
A generated atmospheric image fills the viewport; the headline floats over a gradient scrim.
Full source - H2 full-bleed atmospheric
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)#
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
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-reverseon odd rows) so the page has rhythm; each row pairs aScrollReveal-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-spantiles 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#
- 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
AnimatedStatprimitive in aStaggerContainer. Reduced-motion note:useCountUpinsideAnimatedStatusesrequestAnimationFrame, 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
useReducedMotionand freeze (x: 0, norepeat: Infinitytransition) 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
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-3xlcircle 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-2rules: 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
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>© {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.
- SM1 — Parallax background layer. Editorial or atmospheric directions where a slow layer shift behind content adds depth. Maps
scrollYProgressto a-12%→12%background translate; whenuseReducedMotion()is true the range collapses to0%/0%. - SM2 — Pinned section with stage progression. Product walkthroughs — 3–5 stages advance as the user scrolls through a pinned section (
sticky top-0inside a containerSTAGES.length * 80vhtall); 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,
scaleXdriven byscrollYProgresswithorigin-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 getduration: 0and see the whole heading at once.
Next steps#
- Anti-AI gate — run the full gate after composing.
- Worked examples — see the patterns adapted to five committed directions.