# CCA Component Patterns

CCA components tend to be shorter, more reusable, and more composable than their traditional React equivalents. That's not because of extra abstraction — it's because CCA's separation of content, theming, and code removes entire categories of work from the component. No theme maps. No null checks. No hardcoded strings. What's left is the actual rendering logic.

But the separation also introduces a design question that doesn't come up in traditional React: how do you shape a component's interface when the people using it aren't developers? The params you expose, the content structure you expect, the variants you support — these form a vocabulary that content authors compose with. Getting that vocabulary right produces tighter components and fewer edge cases. The patterns in this guide are what's emerged from that work so far. More will surface as foundations get more ambitious.

If you're coming from a traditional React project — especially one with multiple pages that have similar-but-different sections — you'll want to read [Converting Existing Designs](./converting-existing.md) first. That guide shows how to decompose pages into CCA components. This guide picks up where it leaves off: once you've identified the components, how do you design their interfaces?

---

## Organizing a Foundation

A foundation is a React project. Most of its code is ordinary React components — cards, buttons, layout helpers, renderers — with no special requirements. They live wherever makes sense: `ui/`, `lib/`, inline in the same file, npm packages. CCA doesn't know or care about them.

The only special thing in a foundation is the **content interface**: the small number of section types that content authors can reference by name in frontmatter (`type: Hero`, `type: Features`). These live in `src/sections/`, which is the foundation's **addressable zone** — the build treats everything at the root of this folder as a section type, whether it's a bare file or a folder.

```
src/
├── sections/                # Addressable zone — section types
│   ├── CTA.jsx              # Bare file → section type (no meta.js needed)
│   ├── Hero/
│   │   ├── meta.js          # Explicit content interface (params, presets)
│   │   ├── Hero.jsx         # Entry — or index.jsx, both work
│   │   ├── Centered.jsx     # Internal variant (not addressable)
│   │   └── SplitForm.jsx    # Internal variant
│   ├── Gallery/
│   │   ├── meta.js
│   │   ├── Gallery.jsx
│   │   ├── Grid.jsx         # Internal renderer
│   │   └── Masonry.jsx      # Internal renderer
│   ├── Header/
│   │   └── Header.jsx       # Folder at root → section type (implicit meta)
│   └── Tabs/
│       ├── meta.js
│       ├── Tabs.jsx
│       └── Tab/             # Nested child — meta.js required
│           ├── meta.js
│           └── Tab.jsx
├── components/              # React components (shadcn-compatible, yours, etc.)
│   ├── ui/                  # shadcn primitives
│   │   ├── button.jsx
│   │   ├── card.jsx
│   │   └── badge.jsx
│   ├── TeamCard.jsx
│   └── PricingTier.jsx
├── hooks/                   # Custom React hooks
│   └── useScrollPosition.js
├── styles.css
└── main.js
```

### How discovery works

At the **root of `sections/`**, location is the marker. Any file or folder there is a section type — no `meta.js` needed:

- `CTA.jsx` → section type "CTA" with an implicit empty content interface
- `Header/Header.jsx` → section type "Header" with an implicit empty content interface
- `Hero/meta.js` + `Hero.jsx` → section type "Hero" with explicit params and presets

When `meta.js` is absent, the title is inferred from the component name by splitting PascalCase: `TeamRoster` → "Team Roster", `CTA` → "CTA". Add `meta.js` when you need params, content expectations, or a custom title.

**Deeper nesting** requires explicit `meta.js` — because a nested file could be a helper, a variant, or anything else. This enables two useful patterns:

- **Child section types**: `Tabs/Tab/meta.js` — a section type co-located with its parent, expressing the relationship in the file system
- **Organizational subfolders**: `marketing/Hero/meta.js` — grouping section types by category in large foundations

Files without `meta.js` at nested levels are private implementation — invisible to the build.

`components/` is where your React components live, organized however you like. `hooks/` is for custom React hooks. Everything outside `sections/` is standard React. (The build also falls back to scanning `src/components/` for `meta.js`, so older foundations that put section types there continue to work.)

### Entry file conventions

The build supports two naming conventions for the entry file in a section type folder:

- **Named file**: `Hero/Hero.jsx` — clearer in editor tabs when you have many open
- **Index file**: `Hero/index.jsx` — the traditional React convention

Named files are checked first, so if both exist, `Hero.jsx` wins. At the root of `sections/`, bare files like `CTA.jsx` are always named — `index.jsx` at the root has no meaning (there's no "default section type").

**JSX goes in `.jsx` files** (or `.tsx`). A section type may also be a `.js` file, but only without JSX — JSX compiles in no `.js` file, and the build stops on one, naming the file to rename. The same holds for the foundation's `main.js`, which is plain JavaScript: a component it declares (in `defaultInsets`, for example) lives in a `.jsx` file it imports.

### Your components live in `components/`

The `components/` folder is yours — organize it however makes sense for your project. A `components/ui/` subfolder follows the [shadcn/ui](https://ui.shadcn.com) convention (shadcn's CLI installs there by default). Section types import from them like any other module:

```jsx
// sections/Features/Features.jsx
import { Card, CardContent } from '../../components/ui/card'

export default function Features({ content, params }) {
  return (
    <div className="grid md:grid-cols-3 gap-6">
      {content.items.map((item, i) => (
        <Card key={i}>
          <CardContent>
            <h3 style={{ color: 'var(--heading)' }}>{item.title}</h3>
            <p style={{ color: 'var(--body)' }}>{item.paragraphs[0]}</p>
          </CardContent>
        </Card>
      ))}
    </div>
  )
}
```

CCA doesn't constrain your import graph. Import from `components/`, from npm packages, from `hooks/`, from sibling files inside the section folder — whatever your rendering needs.

### Section-level styles with `Component.className`

The runtime wraps every component in a `<section>` element (with context classes for theming). Section-level styles — vertical padding, borders, background color — go on this wrapper via `Component.className`, not as an extra wrapping div inside your component:

```jsx
function Features({ content, params }) {
  return (
    <div className="max-w-6xl mx-auto px-4">
      {/* ... content rendering ... */}
    </div>
  )
}

Features.className = 'py-16 md:py-24 border-b border-border'

export default Features
```

The component's JSX only needs the content-constraint div (`max-w-6xl mx-auto px-4`). The runtime produces:

```html
<section class="context-light py-16 md:py-24 border-b border-border">
  <div class="max-w-6xl mx-auto px-4">
    ...
  </div>
</section>
```

You can also change the wrapper element with `Component.as`:

```jsx
Header.as = 'nav'       // wraps in <nav> instead of <section>
```

---

## The Front Desk Pattern

Section types naturally use params to adjust their own rendering — `variant: flipped` reverses a flex direction, `columns: 3` sets a grid. That's not a pattern, that's the baseline.

The Front Desk pattern is when a section type does virtually no rendering itself. It reads a param, picks the right helper component, and translates author-friendly vocabulary into developer-oriented props. The section type is a front desk — it greets the request and routes it to the right specialist. Each helper is plain React — it doesn't know about CCA.

Sometimes the workers all render the same content differently — a Gallery that shows images as a grid, masonry layout, or carousel. Sometimes they expect entirely different content — a Hero where one variant has a background image carousel and another has a quote request form. Either way, the front desk declares the **union** of all content its workers might need, and each worker uses what's relevant.

```
src/sections/
└── Gallery/
    ├── meta.js           # Content interface — declares layout param
    ├── Gallery.jsx       # Front desk — reads param, delegates
    ├── Grid.jsx          # Renderer: CSS grid
    ├── Masonry.jsx       # Renderer: CSS columns
    └── Carousel.jsx      # Renderer: horizontal scroll-snap
```

The meta.js declares the choice:

```js
// meta.js
export default {
  params: {
    layout: {
      type: 'select',
      label: 'Layout',
      options: ['grid', 'masonry', 'carousel'],
      default: 'grid',
    },
  },
}
```

And the front desk delegates:

```jsx
// Gallery.jsx
import Grid from './Grid'
import Masonry from './Masonry'
import Carousel from './Carousel'

const layouts = { grid: Grid, masonry: Masonry, carousel: Carousel }

export default function Gallery({ content, params }) {
  const Layout = layouts[params.layout] || Grid
  return <Layout images={content.images} columns={params.columns} />
}
```

That's the entire front desk — six lines of logic. Each renderer receives normalized props and renders one way. `Grid.jsx` knows about CSS grid. `Masonry.jsx` knows about CSS columns. `Carousel.jsx` knows about scroll-snap. None of them know about CCA params or content structure. Adding a fourth layout means adding a file and a key to the map, without touching the existing renderers.

The renderer files are invisible to the build — they're nested inside a section type folder without their own `meta.js`. They're ordinary React modules.

In Gallery's case, the content is the same for every variant — just images with alt text. The `layout` param changes everything about how those images appear. One component name, one content structure, three completely different visual results.

### When the workers need different content

Workers don't always share the same interface. A `Hero` might delegate to a `SliderHero` that renders an image carousel and a `ContactHero` that renders a quote request form — for a plumber's site, say, where some pages have a dramatic rotating hero and others have a "Get a Quote" form. They expect different content and different props.

The front desk declares the **union** of all content its workers might need. Some content won't be used for a given variant, and that's perfectly normal in CCA — params change behavior, and that includes not rendering some content:

```js
// Hero/meta.js — the union of all variants' needs
export default {
  params: {
    variant: { type: 'select', options: ['slider', 'contact'], default: 'slider' },
    slideInterval: { type: 'number', default: 5 },
    density: { type: 'select', options: ['default', 'compact'], default: 'default' },
    style: { type: 'select', options: ['default', 'dramatic'], default: 'default' },
  }
}
```

```jsx
// Hero/Hero.jsx — the front desk
import { SliderHero } from '../../components/SliderHero'
import { ContactHero } from '../../components/ContactHero'

const variants = { slider: SliderHero, contact: ContactHero }

export default function Hero({ content, block, params }) {
  const Variant = variants[params.variant] || SliderHero

  return (
    <Variant
      // Shared — every variant gets these
      title={content.title}
      subtitle={content.paragraphs[0]}
      links={content.links}
      block={block}
      // Content that only some variants use
      images={content.images}
      formData={content.data?.quote}
      // Translated params — author vocabulary → developer props
      interval={params.slideInterval}
      compact={params.density === 'compact'}
      transition={params.style === 'dramatic' ? 'zoom' : 'fade'}
    />
  )
}
```

`SliderHero` uses `images`, `interval`, and `transition`; it ignores `formData` and `compact`. `ContactHero` uses `formData` and `compact`; it ignores `images` and `interval`. Each worker takes what it needs. Some params only matter for certain variants (`slideInterval` for slider, `density` for contact). Some are high-level names that the front desk translates into developer-oriented values (`style: dramatic` → `transition="zoom"`). The content author writes `variant: contact` — they don't know or care about `ContactHero`.

### When the variants are lighter

Not every front desk needs separate files. When variants differ in styling but share the same DOM structure, a class map inside the entry file is enough:

```jsx
// Features.jsx — variants differ in class sets, not structure
const styles = {
  cards: {
    container: 'p-6 rounded-xl bg-section',
    icon: 'w-12 h-12 rounded-lg flex items-center justify-center mb-4',
  },
  minimal: {
    container: 'text-center',
    icon: 'w-14 h-14 rounded-xl flex items-center justify-center mb-4 mx-auto',
  },
  list: {
    container: 'flex gap-4',
    icon: 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
  },
}

export default function Features({ content, params }) {
  const s = styles[params.style] || styles.cards
  // ... render items using s.container, s.icon
}
```

This is a lighter form of the same pattern — the entry file reads a param and selects a rendering strategy. The difference is that the strategies are class maps rather than separate components. Use separate files when variants have different DOM structures or interaction logic; use inline maps when they share the same JSX and only differ in classes. (See [Static Class Maps](#static-class-maps) for more on how these maps work with Tailwind.)

### Variant naming

The instinct when consolidating similar components is to find clever, abstract names for each variant. "What *is* the homepage hero, really? A split-media? A content-with-aside?"

Don't do that. The Front Desk pattern doesn't require elegant abstraction. It requires consolidation.

```js
// meta.js — and this is fine
export default {
  params: {
    variant: {
      type: 'select',
      label: 'Layout',
      options: ['homepage', 'centered', 'directory', 'split-form'],
      default: 'homepage',
    },
  },
}
```

Those names came from where the variants were found. That's fine — "I want the hero to look like the homepage" is a perfectly good selection criterion. You're not shipping an open-source component library where `homepage` would be meaningless. You're shipping a foundation for a site (or family of sites) where these names map to real, tested layouts.

The win is consolidation: one component name, one meta.js, one place to add future variants. Not five components with five names in the content author's palette. The variant vocabulary can evolve later — start with names that are meaningful now.

(If you're consolidating variants from an existing site or AI-generated pages, see [Converting Existing Designs](./converting-existing.md) for the staged migration approach — including how to keep legacy implementations untouched in `components/` while the section type delegates to them.)

---

## Building Blocks

In CCA, each section on a page renders top-to-bottom — one after another. But some designs need sections *side by side*. A "Research & Data" page with two panels in a 2:1 split. A dashboard with three cards across. A comparison layout with two section types next to each other.

You can't get there by making each panel its own top-level section — the runtime renders sections sequentially. The solution is a **Grid** component that arranges child sections within a single grid layout. The Grid is a known, abstract concept — content authors understand "put these things in a grid" even if they don't know CSS. We've found that this level of composition works well without confusing authors, while more exotic composition patterns tend to be harder to explain.

### How child blocks work

The content author nests sections inside a Grid using `@`-prefixed files and `nest:` in `page.yml`:

```
pages/home/
├── page.yml
├── 1-hero.md
├── 2-layout.md          # type: Grid, columns: 2
├── 3-cta.md
├── @field-notes.md      # type: FieldNotes (child of layout)
└── @sightings.md        # type: Sightings (child of layout)
```

```yaml
# page.yml
nest:
  layout: [field-notes, sightings]
```

The Grid component receives `block.childBlocks` — an array of Block instances, each with its own type, content, and params. The component controls the container; the children control themselves:

```jsx
import { ChildBlocks } from '@uniweb/kit'

export default function Grid({ block, params }) {
  const { columns } = params

  const gridCols = {
    2: 'grid-cols-1 md:grid-cols-2',
    3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
    4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
  }

  return (
    <div className={cn('grid gap-6', gridCols[columns])}>
      <ChildBlocks from={block} />
    </div>
  )
}
```

By default, `ChildBlocks` renders each child as a bare component — no wrapper element, no context classes, no background layer. The Grid controls the container; the children render their content directly into the grid cells. This is the right behavior for grid cells, tab panels, carousel slides, and inline children.

### Section treatment with `wrapAs`

In the rare case where child blocks should be independent sections with their own theming context and backgrounds, use `wrapAs` to opt into full section treatment:

```jsx
{/* Each child gets a wrapper element, context classes, background, section ID */}
<ChildBlocks from={block} wrapAs="div" />
<ChildBlocks from={block} wrapAs="article" />
```

Use `wrapAs` when children are **independent sections** that should carry their own theming and backgrounds. Omit it (the default) when children are **visual parts of the parent** — grid cells, tab panels, carousel slides.

### Container queries

Container queries make Grid composition powerful. A Features component that renders a 3-column card grid at full width can drop to a single column when it's inside a 2-column Grid cell — without knowing anything about the Grid. The component queries its own container's width, not the viewport:

```css
@container (min-width: 40rem) {
  .feature-grid { grid-template-columns: repeat(3, 1fr); }
}
```

This means the same component works at full width on one page and inside a grid cell on another — adapting its internal layout to however much space it has. Content authors get flexible composition without components needing to know about each other.

### Why this matters

With one Grid component and a `columns` param, content authors can create any side-by-side layout. A FieldNotes component renders field notes. A Sightings component renders sightings. Neither knows it's inside a grid cell — and neither should. Adding a third column means changing `columns: 3` in the page configuration, not touching any component code.

**The key insight:** Grid components separate *arrangement* from *content*. The Grid controls the container. The content components control themselves. Content authors compose the two by nesting sections in page configuration.

---

## Structured Data in Components

Content authors pass structured data to components using tagged data blocks in markdown:

````markdown
```yaml:nav
items:
  - label: Overview
    icon: lu:layout-grid
    href: "#overview"
  - label: Documentation
    icon: lu:book-open
    href: "#docs"
```
````

The tag after the colon (`nav`) routes the parsed YAML to `content.data.nav`. Your component reads it:

```jsx
import { Icon } from '@uniweb/kit'

export default function Sidebar({ content }) {
  const navItems = content.data?.nav?.items || []

  return (
    <nav>
      {navItems.map((item, i) => (
        <a key={i} href={item.href} className="flex items-center gap-2 text-body hover:text-link">
          <Icon icon={item.icon} size="18" />
          <span>{item.label}</span>
        </a>
      ))}
    </nav>
  )
}
```

### How it works

1. Content author writes ` ```yaml:tag ` in their markdown
2. The content reader parses the YAML and stores it keyed by tag
3. The semantic parser passes it through to `content.data`
4. Your component reads `content.data.tag`

Multiple tagged blocks accumulate — ` ```yaml:nav ` and ` ```yaml:config ` in the same file produce `content.data.nav` and `content.data.config`. Both YAML and JSON formats work (` ```json:tag `).

### When to use structured data vs content items

Use **`content.items`** (heading-separated content) when the data is presentational — cards, team members, FAQ entries — where each item has a natural title, description, image, and links.

Use **`content.data`** (tagged data blocks) when the data is configuration — form field definitions, chart datasets, navigation structures, pricing tier metadata — that doesn't map to the heading/paragraph/image content model.

Some components use both: a Pricing section might use `content.items` for the tier cards (title, price, feature list, CTA link) and `content.data.pricing` for billing toggle configuration.

---

## Multi-Source Rendering

CCA components can receive content from markdown items *or* from data (a query's records, API responses, fetched profiles). The component adapts to whichever source is present.

The `Team` section type in the `international` template shows this clearly (trimmed):

```js
// meta.js — declaring the key is what lets fetched records reach content.data.team
export default {
  data: { team: '@/member' },
}
```

```jsx
export default function Team({ content, params }) {
  // Support both fetched records and markdown items
  const rawMembers = content.data.team || content.items || []

  // Normalize to consistent shape
  const members = rawMembers.map((member) => {
    if (member.name !== undefined) {
      // A record: { name, role, bio, avatar }
      return {
        name: member.name,
        role: member.role,
        bio: member.bio,
        avatar: member.avatar ? { url: member.avatar } : null,
      }
    }
    // A markdown item: H3 is the name, the H4 below it the role
    return {
      name: member.title,
      role: member.subtitle,
      bio: member.paragraphs?.[0],
      avatar: member.images?.[0],
    }
  })

  // From here, render `members` — same code regardless of source
}
```

The content author doesn't choose the data source. They either write markdown:

```markdown
### Jane Smith

#### Engineering Lead

Built the platform from scratch.

![](jane.jpg)
```

Or name a query in the section's frontmatter — records in `records/`, or a public API declared as an external query:

```markdown
---
type: Team
query: team
---
```

The component handles both. The rendering code after normalization is identical — it maps over `members` and renders cards.

**The key insight:** This means the same component serves static sites (markdown content) and dynamic sites (API-backed data) without the content author needing to understand the distinction. The normalization layer is the boundary — above it, two data shapes; below it, one rendering path.

---

## Loading States

When a section type fetches data at runtime (`prerender: false`), there's a gap between the first render and data arrival. Without handling it, the page collapses — header and footer slam together while the section has nothing to show. The component renders immediately and controls its own loading UI.

```js
// meta.js
export default {
  data: {
    articles: '@/article',
  },
}
```

The component checks `block.dataLoading` to decide what to render:

```jsx
import { DataPlaceholder } from '@uniweb/kit'

export default function ArticleList({ content, block, params }) {
  if (block.dataLoading) {
    return (
      <section>
        <h2 style={{ color: 'var(--heading)' }}>{content.title}</h2>
        <DataPlaceholder lines={4} />
      </section>
    )
  }

  const articles = content.data.articles || []
  return (
    <section>
      <h2 style={{ color: 'var(--heading)' }}>{content.title}</h2>
      <div className="grid md:grid-cols-2 gap-6">
        {articles.map((a, i) => <ArticleCard key={i} article={a} />)}
      </div>
    </section>
  )
}
```

Static content — title, paragraphs, items from markdown — is available on the first render. Only the fetched data in `content.data` is pending. That's why the example renders the title immediately and only wraps the data-dependent part in a loading check. The component holds its space, the page doesn't jump, and the content author's headline is visible from the start.

`DataPlaceholder` is a convenience — animated pulse bars from kit. If the design calls for a custom skeleton, read `block.dataLoading` directly and render whatever fits.

---

## Conditional Properties

When a component has many params, the author's editing UI can become cluttered with options that aren't relevant. Conditional properties hide params based on the current value of other params.

Consider a Grid component with a `columns` param and layout ratio options for 2-column layouts:

```js
// meta.js
params: {
  columns: {
    type: 'select',
    label: 'Columns',
    options: [
      { value: 2, label: '2 Columns' },
      { value: 3, label: '3 Columns' },
      { value: 4, label: '4 Columns' },
    ],
    default: 3,
  },
  ratio: {
    type: 'select',
    label: 'Column Ratio',
    options: [
      { value: '50-50', label: 'Equal (50/50)' },
      { value: '40-60', label: 'Narrow / Wide (40/60)' },
      { value: '60-40', label: 'Wide / Narrow (60/40)' },
      { value: '33-67', label: 'Sidebar / Main (33/67)' },
    ],
    default: '50-50',
    condition: { columns: 2 },
  },
}
```

The `ratio` param only appears when `columns` is `2`. For 3- or 4-column layouts, it vanishes — because column ratios don't apply when all columns are equal.

Without this, a Grid with 4 column options and 4 ratio options shows 8 controls at all times. With conditional properties, authors see at most 5 — the 4 column options, plus the ratio options only when they're meaningful.

**The key insight:** Conditional properties aren't just about cleaner UI. They prevent invalid configurations. A content author can't set a 60/40 ratio on a 3-column grid because the option doesn't exist in that state. The meta.js constrains the configuration space to only valid combinations.

---

## Static Class Maps

Tailwind's JIT compiler scans your source files for class strings at build time. It needs to find complete class names — it can't piece together fragments. This has a specific implication for CCA components: param values can't be interpolated into class names.

```jsx
// ❌ Tailwind can't find this — it never appears as a complete string
className={`py-${size}`}

// ❌ This either — template literals are opaque to the scanner
className={`grid-cols-${columns}`}
```

The solution is static maps — objects where every value is a complete, scannable class string:

```jsx
const padding = {
  sm: 'py-2 lg:py-4',
  md: 'py-4 lg:py-8',
  lg: 'py-8 lg:py-16',
}

const gridCols = {
  2: 'sm:grid-cols-2',
  3: 'sm:grid-cols-2 lg:grid-cols-3',
  4: 'sm:grid-cols-2 lg:grid-cols-4',
}

<div className={cn(padding[params.spacing], gridCols[params.columns])}>
```

Every string in the map is a complete token that Tailwind can find by scanning the file. The keys are param values. The values are the CSS.

This is more than a workaround — it's a better design. Each entry in the map is a *complete responsive declaration*. `sm` doesn't mean "small padding" — it means a specific set of breakpoint-aware values that the foundation designer chose. The content author sees "Small", "Medium", "Large" in the UI; the developer controls exactly what each label means in CSS.

This pattern appears throughout the marketing template. The Features component maps `columns` to responsive grid classes. The Gallery maps `columns` to both grid and masonry variants. The CTA maps `theme` to coordinated class sets for section, button, secondary button, and description text — all as static objects.

```jsx
// CTA component — multiple elements, one param, all static
const buttonStyles = {
  primary: 'bg-white text-primary hover:bg-blue-50',
  gradient: 'bg-white text-primary hover:bg-blue-50',
  dark: 'bg-white text-gray-900 hover:bg-gray-100',
  light: 'bg-primary text-white hover:bg-blue-700',
}

const descStyles = {
  primary: 'text-blue-100',
  gradient: 'text-blue-100',
  dark: 'text-gray-400',
  light: 'text-gray-600',
}
```

One `theme` param, four coordinated maps. Every value scannable. Every combination tested.

---

## Parameters as Intent

This has been covered elsewhere (see [Thinking in Contexts](./thinking-in-contexts.md) and [Converting Existing Designs](./converting-existing.md)), but it's worth reinforcing here because the component patterns above depend on it.

Params describe purpose, not CSS. A `spacing: comfortable` param isn't a CSS shortcut; it's a semantic choice that the foundation designer maps to whatever values serve the design. `layout: masonry` isn't `columns: 3` with extra steps; it's a named rendering strategy that encapsulates responsive behavior, gap logic, and break-inside rules.

The constraint is generative — like writing testable code. When you can't expose `className` or `style` directly, you're forced to ask: what are the *meaningful* variations of this component? The answers become the param options, and those options are all tested, all responsive, all compatible with the foundation's design system. You end up with a tighter interface than "pass whatever CSS you want" — fewer invalid states, less surface area to maintain.

But — as discussed in the [Front Desk Pattern](#the-front-desk-pattern) section — "purpose-based" doesn't mean "abstractly named." A Gallery's `layout: masonry` is purpose-based: the author wants a masonry look. A Hero's `variant: homepage` is also purpose-based: the author wants the homepage look. Both are meaningful to the person choosing them. The line is between intent ("I want this layout") and implementation ("give me `grid-cols-3` and `py-8`"). Variant names that came from real pages in a real site are intent — the author recognizes them. CSS fragments are implementation — the author shouldn't see them.

---

## Page Layout

The runtime's default Layout renders every page as three areas — `<header>`, `<main>`, `<footer>` — stacked vertically. Sections within each area render sequentially. This is what you get without any configuration:

```html
<header>  <!-- header area sections (0 or more) -->
<main>    <!-- page body sections (0 or more) -->
<footer>  <!-- footer area sections (0 or more) -->
```

Header and footer content comes from the site's `layout/header.md` and `layout/footer.md` (see [Layout Areas](../reference/layout-areas.md) for areas with several sections). The default Layout ignores left and right panel content (`layout/left.md`, `layout/right.md`).

### `Component.as` — controlling the wrapper tag

By default, the runtime wraps each section in a `<section>` element. Components can change this with the `Component.as` static prop:

```jsx
function Header({ content, params }) {
  // ... header rendering
}

Header.as = 'nav'

export default Header
```

Now the runtime wraps this component in `<nav>` instead of `<section>`. This is purely semantic — use it when the HTML element matters (`nav` for navigation, `article` for blog posts, `aside` for sidebars).

### Header positioning: sticky vs fixed

**To keep a header in view, make the section sticky — not its markup:**

```js
// sections/Header/meta.js
export default {
  sticky: true,
}
```

`sticky` is one of a section's own settings. Your type defaults it, an author turns it off with `sticky: false` in the section's frontmatter, and the runtime makes sticky the box that can stay in view: in the built-in layout, the `<header>` it draws around the header area; in a layout of your own, the box the runtime gives the area. Your component renders in the flow as it would anywhere else, with no spacer to keep in step with its height. A section in the body can stick the same way — a sub-navigation, say — and it stays below the stuck header with nothing to declare: the runtime measures how far the header reaches and publishes it as `--uw-sticky-top`. So can a child section in kit's `<ChildGrid>`, which stays in view beside the column it sits next to ([Component Metadata](../reference/component-metadata.md#sticky)).

**Why `sticky top-0` on your own markup does nothing.** `position: sticky` moves an element only within its parent's box, and a component's markup sits in boxes as tall as itself — the section's wrapper, and the area's. It has no room to move, so it scrolls away with the page. `uniweb dev` warns in the console when a page has a sticky element that cannot move. `Component.className` doesn't help: it styles the section's wrapper, which is as tall as the header too.

**A header that floats over the page** — transparent over a hero, say — is not in the flow at all. Use `fixed` inside the component, which positions against the viewport whatever wraps it, and hold the header's place only where the design needs it:

```jsx
function Header({ content, params }) {
  return (
    <>
      <div className="fixed inset-x-0 top-0 z-50 bg-section/80 backdrop-blur">
        <nav className="max-w-6xl mx-auto px-4 h-16">...</nav>
      </div>
      {!params.floating && <div aria-hidden="true" className="h-16" />}
    </>
  )
}
```

The spacer has to match the header's height exactly, border included, and follow it when the header wraps on a narrow screen — which is why a header that is meant to sit in the flow should be sticky instead.

**Custom layouts** decide where their areas go. An area placed directly in the layout's page column sticks with nothing more; a layout that wraps an area in an element of its own makes that element sticky when `page.isSticky('header')`. See [Custom Layouts](custom-layouts.md#a-sticky-area).

### Custom Layouts

The default header/body/footer stack works for most sites. When you need sidebars, panels, or a non-standard page structure, write a layout in `src/layouts/` — the build discovers it there, the same way it discovers section types in `src/sections/`:

```
src/layouts/
└── DocsLayout/
    ├── index.jsx    ← the layout component
    └── meta.js      ← declares which areas it renders
```

```js
// src/layouts/DocsLayout/meta.js
export default {
  title: 'Documentation',
  areas: ['header', 'footer', 'left', 'right'],
}
```

A layout receives its areas pre-rendered, as props:

```jsx
// src/layouts/DocsLayout/index.jsx
export default function DocsLayout({ page, website, header, body, footer, left, right }) {
  return (
    <div className="min-h-screen flex flex-col">
      <header className="sticky top-0 z-30">{header}</header>
      <div className="flex flex-1">
        {left && <aside className="w-64 hidden md:block">{left}</aside>}
        <main className="flex-1">{body}</main>
        {right && <aside className="w-64 hidden xl:block">{right}</aside>}
      </div>
      <footer>{footer}</footer>
    </div>
  )
}
```

A page opts in with `layout: DocsLayout` in its `page.yml` or `folder.yml`, which cascades to everything beneath it. A foundation can name a `defaultLayout` in `src/main.js` to apply one everywhere, or call a layout `Default`; with neither, a page that names no layout gets the built-in `header`, page, `footer`.

Area content comes from the layout folder — `layout/left.md`, `layout/right.md`, alongside `layout/header.md` and `layout/footer.md`.

**Kit ships no ready-made layout, deliberately.** A layout is your design, and a pre-styled one is unusable by anyone whose design differs — which is what happened to the one kit used to export. What kit does provide is the *behaviour* a documentation shell needs, unstyled, so you write the markup and keep the hard parts:

|  |  |
| --- | --- |
| [`useHeadings()`](../reference/kit-reference.md#useheadings) | The page's headings and the one being read — a contents rail. Derived from content, so it is in the prerendered HTML. |
| [`website.getBranchHierarchy()`](../reference/kit-reference.md#getbranchhierarchy-options) | The page tree for the branch being visited — a navigation rail. |
| [`useMobileMenu()`](../reference/kit-reference.md) | Open/closed state for a drawer, closing itself on navigation. |

The `docs` template is a worked example of all three: `uniweb create my-docs --template docs`.

---

## Inset Components

Some components exist to be placed *inside* other sections — illustrations, data-driven charts, interactive widgets. These are **insets**: foundation components that content authors reference inline via `@` syntax.

### Creating inset components

An inset is a regular section type with `inset: true` in meta.js:

```js
// sections/insets/NetworkDiagram/meta.js
export default {
  inset: true,
  params: {
    variant: {
      type: 'select',
      options: ['full', 'compact'],
      default: 'full',
    },
  },
}
```

```jsx
// sections/insets/NetworkDiagram/NetworkDiagram.jsx
export default function NetworkDiagram({ content, params }) {
  return (
    <figure>
      <svg className={params.variant === 'compact' ? 'h-48' : 'h-80'}>
        {/* ... diagram rendering ... */}
      </svg>
      {content.title && (
        <figcaption className="text-subtle text-sm mt-2">{content.title}</figcaption>
      )}
    </figure>
  )
}
```

The `[description]` text from the markdown becomes `content.title`. Components decide how to use it — as a caption, an accessible label, or not at all.

### Using insets in section types

**Slot-based** — a section with one visual position (SplitContent, Showcase):

```jsx
import { Visual } from '@uniweb/kit'

function SplitContent({ content, block, params }) {
  return (
    <div className="flex gap-12">
      <div className="flex-1">
        <h2 className="text-heading">{content.title}</h2>
        {content.paragraphs.map((p, i) => <p key={i} className="text-body">{p}</p>)}
      </div>
      <Visual inset={block.insets[0]} video={content.videos[0]} image={content.images[0]} className="flex-1" />
    </div>
  )
}
```

`<Visual>` renders the first non-empty candidate from the props you pass. The content author can place any of them — the section type handles all three identically.

**Sequential** — a section that renders content in document order (DocPage, Article):

```jsx
import { Render } from '@uniweb/kit'

function DocPage({ content, block }) {
  return (
    <article className="prose">
      <h1 className="text-heading">{content.title}</h1>
      <Render content={content.sequence} block={block} />
    </article>
  )
}
```

Kit's `Render` component handles `inset_placeholder` nodes in the content flow automatically — insets render at the exact position the author placed them.

### Dual-purpose components

A component can serve as both a standalone section and an inset:

```js
// sections/Testimonial/meta.js
export default {
  family: 'testimonials',
  inset: true,        // offered as an inset (@ references)
  section: true,      // and still offered as a section
}
```

As a page section: `type: Testimonial` in frontmatter. As an inset: `![Customer quote](@Testimonial){style=compact}` within another section's content.

---

## What's Next

These are the patterns we've found so far by building foundations with CCA. More will emerge — especially around:

- **Data-driven composition**, where records and fetched data create section structures that don't exist in the markdown
- **Cross-section communication**, where one component's state (like a filter) affects what other sections display
- **Adaptive complexity**, where a component scales its rendering based on how much content the author provided

This guide will grow as those patterns become clear. If you discover something that feels like a reusable pattern while building your foundation, it probably is.

---

## See Also

- [Thinking in Contexts](./thinking-in-contexts.md) — Semantic theming and when to break the rules
- [Converting Existing Designs](./converting-existing.md) — Bringing existing React code into CCA
- [Content Structure](../reference/content-structure.md) — How markdown becomes `content.items`, `content.data`, and `content.sequence` (includes how attributes flow to components and the foundation's role as gatekeeper)
- [Component Metadata](../reference/component-metadata.md) — The full `meta.js` API
- [Kit Reference](../reference/kit-reference.md) — Hooks, data classes, and utilities from @uniweb/kit