How to compose a new page
A repeatable six-step recipe for assembling a product page out of existing shells, blocks, and components — no new CSS required for 90% of cases.
When to use this
You have a new screen to build and the kits and verticals already cover the patterns you need.
The Huchu DS is opinionated about page construction. Every product page is built from four layers: a shell (frames the chrome), a page header (announces the surface), a body block (the working content), and state surfaces (empty, loading, error, saved). If you compose those four layers in order, you get a page that looks and behaves like the rest of the system without any per-page CSS.
If the recipe below doesn't fit, you probably have a candidate new pattern, not a new page.
The six-step recipe
Each step has exactly one answer. If a step has more than one, the page is doing two jobs — split it.
1 — Pick a shell
Choose the outer container that determines navigation, header position, and width. Most product pages live in App shell (sidebar + topbar). A multi-section reference editor uses Settings shell. A keyboard-only kiosk surface uses the relevant portal shell from portal-shell.css.
2 — Drop in a page header
Every page opens with one — and exactly one — Page header. Title, lede, meta pills, up to three actions on the right (one primary). If the page has tabs, use the with-tabs variant. If it's a detail page, use the compact variant with a back arrow.
3 — Choose the body block
Pick one body shape and stick to it. Each maps to one block:
| If the page is… | Use | What's in it |
|---|---|---|
| A list of records | List page shell | Header + Data toolbar + Data table + pagination |
| A single record | Detail page shell | Detail hero + 1.4fr + 1fr body |
| An edit / create form | Form shell | Header + sections + sticky action bar |
| An executive overview | KPI grid + Module matrix | KPI row + module status grid + Highlights |
| A reference editor | Master data shell | List on the left, editor on the right |
4 — Add state surfaces
Every body block has three states the operator will see: empty, loading, error. Wire them all before shipping. Use Empty state inside the card for first-time / no-results; use Status state for whole-area loading or blocked surfaces; show a Record saved banner on inline confirmation.
5 — Add the action controls
Action controls live in one of three places: header (page-level actions, one primary), toolbar (row-level filters and bulk actions, see Data toolbar), and row (per-record menus via Dropdown menu). Never duplicate an action across two places — pick the most specific scope.
6 — Wire keyboard and a11y
Three minimums: every interactive element is reachable by tab order; ⌘K opens the Command palette with the current page's primary actions surfaced; every icon-only button has an aria-label. Run the mobile checklist before merging.
Worked example — building an Invoices page
Step by step, choosing one block per step and assembling them in order.
Step 1 · Shell
Invoices lives inside the standing operations dashboard. It uses the standard App shell — sidebar on the left, topbar, content area in the centre.
<!-- Outer: app shell already provided by the kit --> <div class="dash-app"> <aside class="dash-sidebar"> … </aside> <main class="dash-main"> <!-- page goes here --> </main> </div>
Step 2 · Page header
Title "Invoices", lede with current filter state, and three actions. "New invoice" is primary; "Export" and "Print" are secondary.
<header class="dash-page-h"> <div> <h1>Invoices</h1> <p class="lede">142 open · ZWG 184,290 outstanding</p> </div> <div class="actions"> <button class="btn btn-quiet">Export</button> <button class="btn btn-secondary">Print</button> <button class="btn btn-primary">New invoice</button> </div> </header>
Step 3 · Body block (data table)
A list of records → List page shell. That gives us a Data toolbar with search + filter chips, the Data table itself, and pagination.
<div class="dash-toolbar"> <input class="toolbar-search" placeholder="Search invoice #" /> <div class="chip-row"> <button class="chip active">Open · 142</button> <button class="chip">Overdue · 23</button> <button class="chip">Draft · 8</button> </div> </div> <table class="dash-table"> … </table>
Step 4 · State surfaces
Three states wired up front, not after the bug report:
- Empty · "No invoices yet" — Empty state inside the table card, with a "Create your first" primary action.
- Loading · 8 row skeletons in the table; toolbar stays interactive.
- Error · Status state in the table region with a "Try again" button.
Step 5 · Action controls
- Page-level · "New invoice", "Export", "Print" — in the header.
- Filter-level · chips and search — in the toolbar.
- Row-level · "Send", "Mark paid", "Void" — in a per-row Dropdown menu. The single most common action ("Open") fires on row click.
- Multi-row · selecting rows swaps the toolbar to Bulk edit mode.
Step 6 · Keyboard and a11y
- Tab order: search → chips → table → pagination → header actions.
/focuses search;⌘Kopens command palette pre-filtered to "Invoice".- Row click opens detail;
Enteron a focused row does the same.Escclears selection. - Every chip has an
aria-pressedstate.
Parts of each composed block
Click into each one for full specs, do/don'ts, and HTML.
Do & don't
Build the page top-down in the recipe order. Skipping straight to the body block produces pages with missing empty states.
Write per-page CSS to "tweak" a block. If a block doesn't fit, file an issue on the block — don't fork it.
Wire empty, loading, and error states in the first PR. Operators will see all three within the first week.
Add two primary buttons to the header. Pick the single most important next action.
Surface the page's primary actions in the command palette so keyboard users can reach them without aiming.
Duplicate the same action across header, toolbar, and row menu. Pick the most specific scope.
What's next
Composed a page and noticed two other pages would use the same structure? You've found a new pattern — read How to compose a new pattern next.