Site pages
The public side: one landing page arranged three ways, and a coming-soon page. The words are placeholders and the same everywhere; what changes is the composition and the navigation. Each page is its own document, so it is shown in a frame.
Split hero, top bar
/ · SiteShell nav=bar · Hero split · FeatureGrid · CtaBand
The root page. A sticky bar with the site's links and both doors; the hero puts the words beside a picture of the product, drawn with its own components and hidden from assistive technology, since the words already say it.
Sourcecomponents/shells/site-shell/doc.ts · components/shells/site-shell/site-shell.tsx · components/shells/site-shell/site-shell.module.css · components/site/doc.ts · components/site/hero/hero.tsx · components/site/feature-grid/feature-grid.tsx · components/site/cta-band/cta-band.tsx · components/site/site-footer/site-footer.tsx · components/site/backdrop/backdrop.module.css
components/shells/site-shell/doc.ts
/**
* SiteShell — the frame a public page sits in.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* links REQUIRED, { href, label }[] — the site's sections
* current? the href of the link where the reader is
* nav? "bar" (default) | "floating" | "sidebar"
* actions? the doors: sign in, sign up, the theme toggle
* brand?, renderLink?, footer?, children
*
* # Behaviour
*
* R1 Follows every shell rule: one main, a skip link first, navigation
* passed in and never lost at narrow widths.
* R2 bar: across the top, sticky, the page scrolling under it. floating: a
* pill over the page's first section, which starts beneath it. sidebar:
* a sticky column with the doors at its foot.
* R3 Narrow, every style becomes a bar with a Menu button; the links and
* the doors move into a drawer (from the left for sidebar), which
* closes when a link is followed.
* R4 The current link is marked with aria-current, not colour alone.
* R5 The footer, when given, is the page's contentinfo landmark.
*/
export {};components/shells/site-shell/site-shell.tsx
"use client";
import { useId, useState, type ReactElement, type ReactNode } from "react";
import { Mark } from "@/components/chrome/mark";
import { Button } from "@/components/forms/button";
import { NavLink } from "@/components/navigation/nav-link";
import {
Drawer,
DrawerContent,
DrawerTrigger,
} from "@/components/overlays/drawer";
import { Menu } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./site-shell.module.css";
export type SiteLink = { href: string; label: string };
export type SiteShellProps = {
/** The site's sections: a handful of links, as data. */
links: readonly SiteLink[];
/** Which link is where the reader is, by href. */
current?: string;
/** How the navigation sits: a bar across the top, a floating pill, or a
* column down the side. All three move into a menu when narrow. */
nav?: "bar" | "floating" | "sidebar";
/** The header's end: sign in, sign up, the theme toggle. */
actions?: ReactNode;
/** Replaces the wordmark; wrap it in a link home. */
brand?: ReactNode;
/** Render with the framework's Link to keep client navigation. */
renderLink?: (link: SiteLink, content: ReactNode) => ReactElement;
/** The page's footer, in the contentinfo landmark. */
footer?: ReactNode;
children: ReactNode;
className?: string;
};
/** The frame a public page sits in: a header with the site's links and its
* doors (sign in, sign up), one main, and a footer. Owns the arrangement
* only; the sections belong to the page. */
export function SiteShell({
links,
current,
nav = "bar",
actions,
brand,
renderLink,
footer,
children,
className,
}: SiteShellProps) {
const id = useId();
const [open, setOpen] = useState(false);
const list = (onNavigate?: () => void) => (
<ul className={styles.list} data-in={onNavigate ? "menu" : "bar"}>
{links.map((link) => {
const active = link.href === current;
return (
<li key={link.href}>
{renderLink ? (
<NavLink asChild active={active} onClick={onNavigate}>
{renderLink(link, link.label)}
</NavLink>
) : (
<NavLink href={link.href} active={active} onClick={onNavigate}>
{link.label}
</NavLink>
)}
</li>
);
})}
</ul>
);
return (
<div className={cn(styles.root, className)} data-nav={nav}>
<a href={`#${id}-main`} className={styles.skip}>
Skip to content
</a>
<header className={styles.header}>
<div className={styles.bar}>
<div className={styles.brand}>{brand ?? <Mark />}</div>
<nav aria-label="Main" className={styles.links}>
{list()}
</nav>
{actions ? <div className={styles.actions}>{actions}</div> : null}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button
variant="quiet"
size="icon"
aria-label="Menu"
className={styles.menu}
>
<Menu aria-hidden="true" />
</Button>
</DrawerTrigger>
<DrawerContent
title="Menu"
side={nav === "sidebar" ? "left" : "right"}
size="sm"
footer={
actions ? (
<div className={styles.drawerActions}>{actions}</div>
) : undefined
}
>
<nav aria-label="Main">{list(() => setOpen(false))}</nav>
</DrawerContent>
</Drawer>
</div>
</header>
<div className={styles.page}>
<main id={`${id}-main`} tabIndex={-1} className={styles.main}>
{children}
</main>
{footer ? <footer className={styles.footer}>{footer}</footer> : null}
</div>
</div>
);
}components/shells/site-shell/site-shell.module.css
@layer composition {
.root {
--site-header-h: calc(var(--control-md) + var(--space-5) * 2);
--site-side-w: 15rem;
display: grid;
min-height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
background: var(--surface-ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-body, var(--text-13));
}
.skip {
position: absolute;
z-index: var(--z-toast);
top: var(--space-3);
left: var(--space-3);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-2);
background: var(--fill);
color: var(--fill-ink);
transform: translateY(-200%);
}
.skip:focus {
transform: none;
}
/* ── The header: brand | links | actions, the menu button when narrow ── */
.header {
position: sticky;
z-index: var(--z-sticky);
top: 0;
}
.bar {
display: flex;
/* Lines up with a Container of width "content" below it. */
width: min(100%, calc(var(--content-max) + 2 * var(--gutter)));
min-height: var(--site-header-h);
align-items: center;
gap: var(--space-7);
margin-inline: auto;
padding: var(--space-5) var(--gutter);
}
.brand {
display: flex;
flex: none;
align-items: center;
}
.brand a {
color: inherit;
text-decoration: none;
}
.links {
min-width: 0;
flex: 1;
}
.list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.list[data-in="menu"] {
flex-direction: column;
align-items: stretch;
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: var(--space-3);
}
.menu {
display: none;
margin-left: auto;
}
.drawerActions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.page {
display: grid;
min-width: 0;
grid-template-rows: minmax(0, 1fr) auto;
}
.main {
min-width: 0;
}
.main:focus {
outline: none;
}
.footer {
border-top: 1px solid var(--line);
}
/* Bar: across the top, over the page as it scrolls under. */
.root[data-nav="bar"] .header {
border-bottom: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-ground) 82%, transparent);
backdrop-filter: blur(12px);
}
/* Floating: a pill that rides above the page. */
.root[data-nav="floating"] .header {
padding: var(--space-5) var(--gutter) 0;
pointer-events: none;
}
.root[data-nav="floating"] .bar {
width: min(100%, 60rem);
min-height: auto;
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
border: 1px solid var(--line);
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--surface-panel) 86%, transparent);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
pointer-events: auto;
}
.root[data-nav="floating"] .list {
justify-content: center;
}
/* The pill overlaps the page's first section, which starts beneath it. */
.root[data-nav="floating"] {
--site-pill-h: calc(var(--control-md) + var(--space-3) * 2 + 2px);
}
.root[data-nav="floating"] .page {
margin-top: calc(-1 * (var(--site-pill-h) + var(--space-5)));
}
/* Sidebar: a column down the side, with the doors at its foot. */
@media (min-width: 52.001rem) {
.root[data-nav="sidebar"] {
grid-template-columns: var(--site-side-w) minmax(0, 1fr);
grid-template-rows: none;
}
.root[data-nav="sidebar"] .header {
height: 100dvh;
border-right: 1px solid var(--line);
background: var(--surface-rail, var(--surface-panel));
}
.root[data-nav="sidebar"] .bar {
width: 100%;
height: 100%;
flex-direction: column;
align-items: stretch;
gap: var(--space-8);
padding: var(--space-7) var(--space-5);
}
.root[data-nav="sidebar"] .brand {
padding-inline: var(--space-4);
}
.root[data-nav="sidebar"] .list {
flex-direction: column;
align-items: stretch;
}
.root[data-nav="sidebar"] .actions {
flex-direction: column;
align-items: stretch;
}
}
/* Narrow: every style becomes a bar with a menu button; the links and
doors move into the menu. */
@media (max-width: 52rem) {
.root[data-nav="sidebar"] .header {
border-bottom: 1px solid var(--line);
background: var(--surface-ground);
}
.links,
.actions {
display: none;
}
.menu {
display: inline-flex;
}
}
}components/site/doc.ts
/**
* site — the sections a public page is built from.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Members
*
* Hero title (the page's h1), eyebrow?, description?, actions?,
* media?, layout "split" | "centered", backdrop?
* FeatureGrid title (h2), eyebrow?, intro?, features { title,
* description, icon? }[], columns 2 | 3, id?
* CtaBand title (h2), description?, actions — on the gradient
* SiteFooter brand, tagline?, columns { title, links }[], legal?
* backdrop the preset's gradient mesh, as a class
*
* # Rules for every member
*
* R1 Each section is labelled by its own heading, and headings follow the
* outline: one h1 (the Hero's), h2 per section, h3 inside.
* R2 Sections line up with the SiteShell's header: content width, the
* same gutter.
* R3 The gradient is the preset's own (--gradient-1…3), mixed over the
* ground at --gradient-strength, which each mode sets so text on it
* keeps its contrast. It drifts only when asked and never under
* reduced motion.
* R4 Columns collapse to one as the page narrows; nothing scrolls
* sideways.
* R5 Sections own arrangement only; the words are the page's.
*/
export {};components/site/hero/hero.tsx
import { useId, type ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import { backdrop } from "../backdrop";
import styles from "./hero.module.css";
export type HeroProps = {
/** The page's headline, rendered as its h1. */
title: string;
/** A short line above the title: a category, an announcement. */
eyebrow?: ReactNode;
description?: ReactNode;
/** The doors: sign up, sign in, a secondary link. */
actions?: ReactNode;
/** Beside the text (split) or under it (centered): a product picture, a
* form. Decorative pictures should be aria-hidden by the caller. */
media?: ReactNode;
/** Text beside the media, or centred above it. Stacks when narrow. */
layout?: "split" | "centered";
/** Wash the section in the preset's gradient. */
backdrop?: boolean;
/** Under the actions: fine print, a sign-up form. */
children?: ReactNode;
className?: string;
};
/** A page's opening section: the headline, what it is, and what to do next. */
export function Hero({
title,
eyebrow,
description,
actions,
media,
layout = "split",
backdrop: washed = false,
children,
className,
}: HeroProps) {
const id = useId();
return (
<section
aria-labelledby={`${id}-title`}
data-layout={layout}
data-strength="soft"
className={cn(styles.root, washed && backdrop.mesh, className)}
>
<Container width="content" className={styles.inner}>
<div className={styles.copy}>
{eyebrow ? <div className={styles.eyebrow}>{eyebrow}</div> : null}
<Heading level={1} size="display" id={`${id}-title`}>
{title}
</Heading>
{description ? (
<Text size="lg" tone="muted" className={styles.description}>
{description}
</Text>
) : null}
{actions ? <div className={styles.actions}>{actions}</div> : null}
{children}
</div>
{media ? <div className={styles.media}>{media}</div> : null}
</Container>
</section>
);
}components/site/feature-grid/feature-grid.tsx
import { useId, type ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import type { LucideIcon } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./feature-grid.module.css";
export type Feature = {
title: string;
description: ReactNode;
icon?: LucideIcon;
};
export type FeatureGridProps = {
/** The section's h2. */
title: string;
eyebrow?: ReactNode;
intro?: ReactNode;
features: readonly Feature[];
/** Most columns at full width; fewer as it narrows. */
columns?: 2 | 3;
/** For in-page links from the navigation. */
id?: string;
className?: string;
};
/** What the product does, as a titled grid of short points. */
export function FeatureGrid({
title,
eyebrow,
intro,
features,
columns = 3,
id,
className,
}: FeatureGridProps) {
const heading = useId();
return (
<section
id={id}
aria-labelledby={heading}
className={cn(styles.root, className)}
>
<Container width="content" className={styles.inner}>
<div className={styles.head}>
{eyebrow ? <div className={styles.eyebrow}>{eyebrow}</div> : null}
<Heading level={2} size="lg" id={heading}>
{title}
</Heading>
{intro ? <Text tone="muted">{intro}</Text> : null}
</div>
<ul className={styles.grid} data-columns={columns}>
{features.map((feature) => {
const Icon = feature.icon;
return (
<li key={feature.title} className={styles.item}>
{Icon ? (
<span className={styles.icon}>
<Icon aria-hidden="true" />
</span>
) : null}
<Heading level={3} size="sm">
{feature.title}
</Heading>
<Text tone="muted">{feature.description}</Text>
</li>
);
})}
</ul>
</Container>
</section>
);
}components/site/cta-band/cta-band.tsx
import { useId, type ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import { backdrop } from "../backdrop";
import styles from "./cta-band.module.css";
export type CtaBandProps = {
/** The section's h2. */
title: string;
description?: ReactNode;
actions: ReactNode;
className?: string;
};
/** The closing ask, on the preset's gradient: one line, one or two doors. */
export function CtaBand({
title,
description,
actions,
className,
}: CtaBandProps) {
const id = useId();
return (
<section aria-labelledby={id} className={cn(styles.root, className)}>
<Container width="content">
<div className={cn(styles.panel, backdrop.mesh)}>
<div className={styles.copy}>
<Heading level={2} size="lg" id={id}>
{title}
</Heading>
{description ? <Text tone="muted">{description}</Text> : null}
</div>
<div className={styles.actions}>{actions}</div>
</div>
</Container>
</section>
);
}components/site/site-footer/site-footer.tsx
import type { ReactElement, ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./site-footer.module.css";
export type FooterLink = { href: string; label: string };
export type FooterColumn = { title: string; links: readonly FooterLink[] };
export type SiteFooterProps = {
brand: ReactNode;
/** A line under the brand: what the product is. */
tagline?: ReactNode;
columns: readonly FooterColumn[];
/** The bottom row: copyright, legal links. */
legal?: ReactNode;
/** Render with the framework's Link to keep client navigation. */
renderLink?: (link: FooterLink) => ReactElement;
className?: string;
};
/** The site's index: the brand, columns of links, and the fine print. Goes
* in SiteShell's footer, which is the contentinfo landmark. */
export function SiteFooter({
brand,
tagline,
columns,
legal,
renderLink,
className,
}: SiteFooterProps) {
return (
<Container width="content" className={cn(styles.root, className)}>
<div className={styles.top}>
<div className={styles.brand}>
{brand}
{tagline ? (
<Text size="sm" tone="muted">
{tagline}
</Text>
) : null}
</div>
<div className={styles.columns}>
{columns.map((column) => (
<nav key={column.title} aria-label={column.title}>
<p className={styles.title}>{column.title}</p>
<ul className={styles.list}>
{column.links.map((link) => (
<li key={link.href + link.label}>
{renderLink ? (
renderLink(link)
) : (
<a href={link.href}>{link.label}</a>
)}
</li>
))}
</ul>
</nav>
))}
</div>
</div>
{legal ? <div className={styles.legal}>{legal}</div> : null}
</Container>
);
}components/site/backdrop/backdrop.module.css
@layer composition {
/* The preset's gradient as a soft mesh behind content: three radial washes
of --gradient-1…3 over the ground. Each is mixed at --gradient-strength,
which each mode sets so text on top keeps its contrast. */
.mesh {
position: relative;
isolation: isolate;
}
.mesh::before {
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
background:
radial-gradient(
55% 65% at 12% 18%,
color-mix(
in oklab,
var(--gradient-1) var(--gradient-strength),
transparent
),
transparent 70%
),
radial-gradient(
50% 60% at 88% 12%,
color-mix(
in oklab,
var(--gradient-2) var(--gradient-strength),
transparent
),
transparent 70%
),
radial-gradient(
70% 70% at 55% 100%,
color-mix(
in oklab,
var(--gradient-3) var(--gradient-strength),
transparent
),
transparent 72%
);
background-size: 140% 140%;
content: "";
pointer-events: none;
}
/* Quieter: behind a hero that has text and a picture to carry. */
.mesh[data-strength="soft"]::before {
opacity: 0.55;
}
/* Drifting: the washes wander slowly. Never under reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.mesh[data-drift]::before {
animation: drift 28s ease-in-out infinite alternate;
}
}
@keyframes drift {
from {
background-position:
0% 0%,
100% 0%,
50% 100%;
}
to {
background-position:
30% 40%,
60% 30%,
20% 60%;
}
}
}Sign up
AuthShell centred · AuthShell split with an aside
One form, two layouts. The split page puts the same form on the right half and the product on the left, on the preset's gradient. The left half is supplementary: it follows the form in reading order and is left out on narrow screens. Both read ?email= from the landing pages' forms.
Sourcecomponents/shells/auth-shell/doc.ts · components/shells/auth-shell/auth-shell.tsx · components/shells/auth-shell/auth-shell.module.css
components/shells/auth-shell/doc.ts
/**
* AuthShell — the frame every signed-out screen shares.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* title REQUIRED, the page's h1
* description? under the title
* actions? beside the brand: theme, language
* brand? replaces the wordmark
* footer? under the card: the link to the other door
* layout? "centered" (default) | "split"
* aside? split only: the other half — the product, a quote
* children the form
*
* # Behaviour
*
* R1 Centred in the viewport in one narrow column; on a short viewport it
* scrolls instead of clipping.
* R2 The title is required and is the h1: the brand is not a heading.
* R3 The shell is the main landmark. It owns arrangement only; the form,
* its state, and its errors belong to the page.
* R4 Split puts the form on the right half, without a card, and the aside
* on the left, held in view while the form scrolls. The aside is
* supplementary: it follows the form in reading order, and below a
* tablet's width it is left out and the form takes the screen.
*/
export {};components/shells/auth-shell/auth-shell.tsx
import type { ReactNode } from "react";
import { Mark } from "@/components/chrome/mark";
import { Card } from "@/components/display/card";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./auth-shell.module.css";
export type AuthShellProps = {
/** Required, and rendered as the page's h1: the wordmark is not a heading,
* and a reader needs something to orient on. */
title: string;
description?: ReactNode;
/** Above the card, beside the brand: the theme toggle, a language menu. */
actions?: ReactNode;
/** Replaces the wordmark; wrap it in a link home if there is one. */
brand?: ReactNode;
/** Under the card: the link to the other door. */
footer?: ReactNode;
/** One centred card (default), or the form on one half of the screen and
* `aside` on the other. */
layout?: "centered" | "split";
/** The other half, in split: the product, a promise, a quote. It is
* supplementary, so it follows the form in reading order and is left out
* when the screen is narrow. */
aside?: ReactNode;
children: ReactNode;
className?: string;
};
/** The frame every signed-out screen shares: centred, one narrow column, one
* card. It owns the arrangement only — the form belongs to the page. */
export function AuthShell({
title,
description,
actions,
brand,
footer,
layout = "centered",
aside,
children,
className,
}: AuthShellProps) {
const head = (
<div className={styles.head}>
<Heading level={1} size={layout === "split" ? "lg" : "md"}>
{title}
</Heading>
{description ? <Text tone="muted">{description}</Text> : null}
</div>
);
if (layout === "split")
return (
<main className={cn(styles.root, styles.split, className)}>
<div className={styles.pane}>
<div className={styles.top}>
{brand ?? <Mark />}
{actions}
</div>
<div className={styles.body}>
{head}
{children}
{footer ? <div className={styles.footer}>{footer}</div> : null}
</div>
</div>
{aside ? <aside className={styles.aside}>{aside}</aside> : null}
</main>
);
return (
<main className={cn(styles.root, className)}>
<Container width="narrow">
<div className={styles.column}>
<div className={styles.top}>
{brand ?? <Mark />}
{actions}
</div>
<Card as="div" elevation="raised" className={styles.card}>
{head}
{children}
</Card>
{footer ? <div className={styles.footer}>{footer}</div> : null}
</div>
</Container>
</main>
);
}components/shells/auth-shell/auth-shell.module.css
@layer composition {
.root {
display: grid;
min-height: 100dvh;
align-content: center;
padding-block: var(--space-9);
background: var(--surface-ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-body, var(--text-13));
}
.column {
display: grid;
gap: var(--space-7);
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.card {
display: grid;
gap: var(--space-7);
padding: var(--space-9);
}
.head {
display: grid;
gap: var(--space-3);
}
.footer {
color: var(--ink-2);
font-size: var(--text-body, var(--text-13));
text-align: center;
}
/* ── Split: the form on one half, the aside on the other ── */
.split {
grid-template-areas: "aside pane";
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-content: stretch;
padding-block: 0;
}
.pane {
display: grid;
min-width: 0;
grid-area: pane;
grid-template-rows: auto 1fr;
padding: var(--space-7) var(--gutter);
}
.body {
display: grid;
width: min(100%, var(--narrow-max));
align-content: center;
gap: var(--space-7);
margin-inline: auto;
padding-block: var(--space-10);
}
.split .footer {
text-align: start;
}
/* In reading order it comes after the form; on screen it is the left
half, held in view while the form scrolls. */
.aside {
position: sticky;
top: 0;
height: 100dvh;
min-width: 0;
grid-area: aside;
overflow: hidden;
border-right: 1px solid var(--line);
}
@media (max-width: 56rem) {
.split {
grid-template-areas: "pane";
grid-template-columns: minmax(0, 1fr);
}
.aside {
display: none;
}
}
}Sign in
the same two layouts · the side panel says what is new
The side panel changes with the door. Sign-up says what you are signing up for; sign-in says what is new since you were last here. The two split pages link to each other, so a reader stays in one layout.
Sourcecomponents/shells/auth-shell/doc.ts · components/shells/auth-shell/auth-shell.tsx · components/shells/auth-shell/auth-shell.module.css
components/shells/auth-shell/doc.ts
/**
* AuthShell — the frame every signed-out screen shares.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* title REQUIRED, the page's h1
* description? under the title
* actions? beside the brand: theme, language
* brand? replaces the wordmark
* footer? under the card: the link to the other door
* layout? "centered" (default) | "split"
* aside? split only: the other half — the product, a quote
* children the form
*
* # Behaviour
*
* R1 Centred in the viewport in one narrow column; on a short viewport it
* scrolls instead of clipping.
* R2 The title is required and is the h1: the brand is not a heading.
* R3 The shell is the main landmark. It owns arrangement only; the form,
* its state, and its errors belong to the page.
* R4 Split puts the form on the right half, without a card, and the aside
* on the left, held in view while the form scrolls. The aside is
* supplementary: it follows the form in reading order, and below a
* tablet's width it is left out and the form takes the screen.
*/
export {};components/shells/auth-shell/auth-shell.tsx
import type { ReactNode } from "react";
import { Mark } from "@/components/chrome/mark";
import { Card } from "@/components/display/card";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./auth-shell.module.css";
export type AuthShellProps = {
/** Required, and rendered as the page's h1: the wordmark is not a heading,
* and a reader needs something to orient on. */
title: string;
description?: ReactNode;
/** Above the card, beside the brand: the theme toggle, a language menu. */
actions?: ReactNode;
/** Replaces the wordmark; wrap it in a link home if there is one. */
brand?: ReactNode;
/** Under the card: the link to the other door. */
footer?: ReactNode;
/** One centred card (default), or the form on one half of the screen and
* `aside` on the other. */
layout?: "centered" | "split";
/** The other half, in split: the product, a promise, a quote. It is
* supplementary, so it follows the form in reading order and is left out
* when the screen is narrow. */
aside?: ReactNode;
children: ReactNode;
className?: string;
};
/** The frame every signed-out screen shares: centred, one narrow column, one
* card. It owns the arrangement only — the form belongs to the page. */
export function AuthShell({
title,
description,
actions,
brand,
footer,
layout = "centered",
aside,
children,
className,
}: AuthShellProps) {
const head = (
<div className={styles.head}>
<Heading level={1} size={layout === "split" ? "lg" : "md"}>
{title}
</Heading>
{description ? <Text tone="muted">{description}</Text> : null}
</div>
);
if (layout === "split")
return (
<main className={cn(styles.root, styles.split, className)}>
<div className={styles.pane}>
<div className={styles.top}>
{brand ?? <Mark />}
{actions}
</div>
<div className={styles.body}>
{head}
{children}
{footer ? <div className={styles.footer}>{footer}</div> : null}
</div>
</div>
{aside ? <aside className={styles.aside}>{aside}</aside> : null}
</main>
);
return (
<main className={cn(styles.root, className)}>
<Container width="narrow">
<div className={styles.column}>
<div className={styles.top}>
{brand ?? <Mark />}
{actions}
</div>
<Card as="div" elevation="raised" className={styles.card}>
{head}
{children}
</Card>
{footer ? <div className={styles.footer}>{footer}</div> : null}
</div>
</Container>
</main>
);
}components/shells/auth-shell/auth-shell.module.css
@layer composition {
.root {
display: grid;
min-height: 100dvh;
align-content: center;
padding-block: var(--space-9);
background: var(--surface-ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-body, var(--text-13));
}
.column {
display: grid;
gap: var(--space-7);
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.card {
display: grid;
gap: var(--space-7);
padding: var(--space-9);
}
.head {
display: grid;
gap: var(--space-3);
}
.footer {
color: var(--ink-2);
font-size: var(--text-body, var(--text-13));
text-align: center;
}
/* ── Split: the form on one half, the aside on the other ── */
.split {
grid-template-areas: "aside pane";
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-content: stretch;
padding-block: 0;
}
.pane {
display: grid;
min-width: 0;
grid-area: pane;
grid-template-rows: auto 1fr;
padding: var(--space-7) var(--gutter);
}
.body {
display: grid;
width: min(100%, var(--narrow-max));
align-content: center;
gap: var(--space-7);
margin-inline: auto;
padding-block: var(--space-10);
}
.split .footer {
text-align: start;
}
/* In reading order it comes after the form; on screen it is the left
half, held in view while the form scrolls. */
.aside {
position: sticky;
top: 0;
height: 100dvh;
min-width: 0;
grid-area: aside;
overflow: hidden;
border-right: 1px solid var(--line);
}
@media (max-width: 56rem) {
.split {
grid-template-areas: "pane";
grid-template-columns: minmax(0, 1fr);
}
.aside {
display: none;
}
}
}Coming soon
one page · six presets · --gradient-1…3
Each preset brings its own gradient. Three hues from its palette (--gradient-1 to --gradient-3) are mixed over the ground at --gradient-strength, which each mode sets, so the text keeps its contrast in light and dark. The mode follows the gallery's; the preset is fixed per frame with ?preset=. It drifts slowly, never under reduced motion.
Sourcecomponents/site/doc.ts · components/site/hero/hero.tsx · components/site/feature-grid/feature-grid.tsx · components/site/cta-band/cta-band.tsx · components/site/site-footer/site-footer.tsx · components/site/backdrop/backdrop.module.css
components/site/doc.ts
/**
* site — the sections a public page is built from.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Members
*
* Hero title (the page's h1), eyebrow?, description?, actions?,
* media?, layout "split" | "centered", backdrop?
* FeatureGrid title (h2), eyebrow?, intro?, features { title,
* description, icon? }[], columns 2 | 3, id?
* CtaBand title (h2), description?, actions — on the gradient
* SiteFooter brand, tagline?, columns { title, links }[], legal?
* backdrop the preset's gradient mesh, as a class
*
* # Rules for every member
*
* R1 Each section is labelled by its own heading, and headings follow the
* outline: one h1 (the Hero's), h2 per section, h3 inside.
* R2 Sections line up with the SiteShell's header: content width, the
* same gutter.
* R3 The gradient is the preset's own (--gradient-1…3), mixed over the
* ground at --gradient-strength, which each mode sets so text on it
* keeps its contrast. It drifts only when asked and never under
* reduced motion.
* R4 Columns collapse to one as the page narrows; nothing scrolls
* sideways.
* R5 Sections own arrangement only; the words are the page's.
*/
export {};components/site/hero/hero.tsx
import { useId, type ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import { backdrop } from "../backdrop";
import styles from "./hero.module.css";
export type HeroProps = {
/** The page's headline, rendered as its h1. */
title: string;
/** A short line above the title: a category, an announcement. */
eyebrow?: ReactNode;
description?: ReactNode;
/** The doors: sign up, sign in, a secondary link. */
actions?: ReactNode;
/** Beside the text (split) or under it (centered): a product picture, a
* form. Decorative pictures should be aria-hidden by the caller. */
media?: ReactNode;
/** Text beside the media, or centred above it. Stacks when narrow. */
layout?: "split" | "centered";
/** Wash the section in the preset's gradient. */
backdrop?: boolean;
/** Under the actions: fine print, a sign-up form. */
children?: ReactNode;
className?: string;
};
/** A page's opening section: the headline, what it is, and what to do next. */
export function Hero({
title,
eyebrow,
description,
actions,
media,
layout = "split",
backdrop: washed = false,
children,
className,
}: HeroProps) {
const id = useId();
return (
<section
aria-labelledby={`${id}-title`}
data-layout={layout}
data-strength="soft"
className={cn(styles.root, washed && backdrop.mesh, className)}
>
<Container width="content" className={styles.inner}>
<div className={styles.copy}>
{eyebrow ? <div className={styles.eyebrow}>{eyebrow}</div> : null}
<Heading level={1} size="display" id={`${id}-title`}>
{title}
</Heading>
{description ? (
<Text size="lg" tone="muted" className={styles.description}>
{description}
</Text>
) : null}
{actions ? <div className={styles.actions}>{actions}</div> : null}
{children}
</div>
{media ? <div className={styles.media}>{media}</div> : null}
</Container>
</section>
);
}components/site/feature-grid/feature-grid.tsx
import { useId, type ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import type { LucideIcon } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./feature-grid.module.css";
export type Feature = {
title: string;
description: ReactNode;
icon?: LucideIcon;
};
export type FeatureGridProps = {
/** The section's h2. */
title: string;
eyebrow?: ReactNode;
intro?: ReactNode;
features: readonly Feature[];
/** Most columns at full width; fewer as it narrows. */
columns?: 2 | 3;
/** For in-page links from the navigation. */
id?: string;
className?: string;
};
/** What the product does, as a titled grid of short points. */
export function FeatureGrid({
title,
eyebrow,
intro,
features,
columns = 3,
id,
className,
}: FeatureGridProps) {
const heading = useId();
return (
<section
id={id}
aria-labelledby={heading}
className={cn(styles.root, className)}
>
<Container width="content" className={styles.inner}>
<div className={styles.head}>
{eyebrow ? <div className={styles.eyebrow}>{eyebrow}</div> : null}
<Heading level={2} size="lg" id={heading}>
{title}
</Heading>
{intro ? <Text tone="muted">{intro}</Text> : null}
</div>
<ul className={styles.grid} data-columns={columns}>
{features.map((feature) => {
const Icon = feature.icon;
return (
<li key={feature.title} className={styles.item}>
{Icon ? (
<span className={styles.icon}>
<Icon aria-hidden="true" />
</span>
) : null}
<Heading level={3} size="sm">
{feature.title}
</Heading>
<Text tone="muted">{feature.description}</Text>
</li>
);
})}
</ul>
</Container>
</section>
);
}components/site/cta-band/cta-band.tsx
import { useId, type ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Heading } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import { backdrop } from "../backdrop";
import styles from "./cta-band.module.css";
export type CtaBandProps = {
/** The section's h2. */
title: string;
description?: ReactNode;
actions: ReactNode;
className?: string;
};
/** The closing ask, on the preset's gradient: one line, one or two doors. */
export function CtaBand({
title,
description,
actions,
className,
}: CtaBandProps) {
const id = useId();
return (
<section aria-labelledby={id} className={cn(styles.root, className)}>
<Container width="content">
<div className={cn(styles.panel, backdrop.mesh)}>
<div className={styles.copy}>
<Heading level={2} size="lg" id={id}>
{title}
</Heading>
{description ? <Text tone="muted">{description}</Text> : null}
</div>
<div className={styles.actions}>{actions}</div>
</div>
</Container>
</section>
);
}components/site/site-footer/site-footer.tsx
import type { ReactElement, ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./site-footer.module.css";
export type FooterLink = { href: string; label: string };
export type FooterColumn = { title: string; links: readonly FooterLink[] };
export type SiteFooterProps = {
brand: ReactNode;
/** A line under the brand: what the product is. */
tagline?: ReactNode;
columns: readonly FooterColumn[];
/** The bottom row: copyright, legal links. */
legal?: ReactNode;
/** Render with the framework's Link to keep client navigation. */
renderLink?: (link: FooterLink) => ReactElement;
className?: string;
};
/** The site's index: the brand, columns of links, and the fine print. Goes
* in SiteShell's footer, which is the contentinfo landmark. */
export function SiteFooter({
brand,
tagline,
columns,
legal,
renderLink,
className,
}: SiteFooterProps) {
return (
<Container width="content" className={cn(styles.root, className)}>
<div className={styles.top}>
<div className={styles.brand}>
{brand}
{tagline ? (
<Text size="sm" tone="muted">
{tagline}
</Text>
) : null}
</div>
<div className={styles.columns}>
{columns.map((column) => (
<nav key={column.title} aria-label={column.title}>
<p className={styles.title}>{column.title}</p>
<ul className={styles.list}>
{column.links.map((link) => (
<li key={link.href + link.label}>
{renderLink ? (
renderLink(link)
) : (
<a href={link.href}>{link.label}</a>
)}
</li>
))}
</ul>
</nav>
))}
</div>
</div>
{legal ? <div className={styles.legal}>{legal}</div> : null}
</Container>
);
}components/site/backdrop/backdrop.module.css
@layer composition {
/* The preset's gradient as a soft mesh behind content: three radial washes
of --gradient-1…3 over the ground. Each is mixed at --gradient-strength,
which each mode sets so text on top keeps its contrast. */
.mesh {
position: relative;
isolation: isolate;
}
.mesh::before {
position: absolute;
z-index: -1;
inset: 0;
border-radius: inherit;
background:
radial-gradient(
55% 65% at 12% 18%,
color-mix(
in oklab,
var(--gradient-1) var(--gradient-strength),
transparent
),
transparent 70%
),
radial-gradient(
50% 60% at 88% 12%,
color-mix(
in oklab,
var(--gradient-2) var(--gradient-strength),
transparent
),
transparent 70%
),
radial-gradient(
70% 70% at 55% 100%,
color-mix(
in oklab,
var(--gradient-3) var(--gradient-strength),
transparent
),
transparent 72%
);
background-size: 140% 140%;
content: "";
pointer-events: none;
}
/* Quieter: behind a hero that has text and a picture to carry. */
.mesh[data-strength="soft"]::before {
opacity: 0.55;
}
/* Drifting: the washes wander slowly. Never under reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.mesh[data-drift]::before {
animation: drift 28s ease-in-out infinite alternate;
}
}
@keyframes drift {
from {
background-position:
0% 0%,
100% 0%,
50% 100%;
}
to {
background-position:
30% 40%,
60% 30%,
20% 60%;
}
}
}