Composition Recipes
Use this guide when building complete app screens from Porchlight primitives. It is intentionally prescriptive: pick a recipe, keep the semantic component contracts intact, and add only small app-specific glue where the layout needs it.
Model checklist
- Use
.pl-l-*layout primitives for page structure:.pl-l-app-shell,.pl-l-container,.pl-l-stack,.pl-l-grid,.pl-l-cluster,.pl-l-sidebar. - Use
.pl-c-*components for UI semantics and component chrome: cards, forms, tables, toolbars, tabs, pagination, badges, nav, stats. - Use
.pl-u-*utilities for small text and overflow adjustments:.pl-u-muted,.pl-u-muted-sm,.pl-u-truncate,.pl-u-sr-only,.pl-u-min-0. - Use app CSS only for glue: page-specific widths, a one-off split, or a local
alignment fix. Prefer tokens such as
--pl-space-4over new spacing values. - Keep native HTML semantics: real tables, forms, headings, labels, nav, buttons, links, fieldsets, and legends.
Avoid these mistakes
- Do not make div-based tables. Use
<table>inside.pl-c-table-wrap. - Do not put page headers inside
.pl-c-toolbar; use.pl-c-page-headerinside padded content and.pl-c-toolbaron data-region edges. - Do not nest cards inside cards for layout. Use
.pl-l-grid,.pl-l-sidebar,.pl-l-stack, or sibling.pl-c-cardelements instead. - Do not omit
.pl-c-table-wrap; it provides scroll, borders, sticky context, and container-query behavior for.pl-c-table. - Do not invent spacing systems. Use
.pl-l-stack,.pl-l-grid,.pl-l-cluster, and--pl-space-*tokens. - Do not use visible labels as placeholders only. Use real visible labels or
.pl-u-sr-onlyfor compact controls.
App Shell Recipe
Use this for SaaS workspaces, dashboards, inboxes, consoles, and admin tools.
The shell owns the topbar/sidebar/main regions; the page content goes inside
.pl-l-app-shell__main.
<div class="pl-l-app-shell">
<header class="pl-l-app-shell__topbar">
<div class="pl-l-cluster" style="--pl-l-cluster-justify: space-between;">
<strong>Acme</strong>
<button class="pl-c-button" data-variant="ghost">Search</button>
</div>
</header>
<aside class="pl-l-app-shell__sidebar">
<nav class="pl-c-nav" aria-label="Main navigation">
<a class="pl-c-nav__item" href="/dashboard" aria-current="page">
<span class="pl-c-nav__label">Dashboard</span>
</a>
<a class="pl-c-nav__item" href="/accounts">
<span class="pl-c-nav__label">Accounts</span>
</a>
</nav>
</aside>
<main class="pl-l-app-shell__main">
<div
class="pl-l-container pl-l-stack"
style="--pl-l-stack-gap: var(--pl-space-6);"
>
<!-- page sections -->
</div>
</main>
</div>
Dashboard Recipe
Use .pl-c-page-header for the page title and actions, .pl-l-grid for responsive
metric cards, and .pl-c-card[data-surface="app"] around each KPI.
<div
class="pl-l-container pl-l-stack"
style="--pl-l-stack-gap: var(--pl-space-6);"
>
<div class="pl-c-page-header">
<div class="pl-c-page-header__heading">
<h1 class="pl-c-page-header__title">Dashboard</h1>
<span class="pl-c-page-header__subtitle"
>Overview of workspace health</span
>
</div>
<div class="pl-c-page-header__actions">
<button class="pl-c-button" data-variant="secondary">Export</button>
<button class="pl-c-button" data-variant="primary">New report</button>
</div>
</div>
<div class="pl-l-grid" style="--pl-l-grid-min: 14rem;">
<section class="pl-c-card" data-surface="app">
<div class="pl-c-card__body">
<div class="pl-c-stat">
<span class="pl-c-stat__label">Monthly revenue</span>
<div class="pl-c-stat__value">
$48,200<span class="pl-c-stat__unit">/mo</span>
</div>
<span class="pl-c-stat__trend" data-direction="up">12.4%</span>
</div>
</div>
</section>
</div>
</div>
Data Region Recipe
Use this for tables with headers, filters, actions, selected rows, and
pagination. The card frames the region; toolbars sit at the top and bottom;
the table must stay inside .pl-c-table-wrap.
<section class="pl-c-card" data-surface="app">
<div class="pl-c-toolbar">
<div class="pl-c-toolbar__group">
<label class="pl-c-field">
<span class="pl-u-sr-only">Search accounts</span>
<input
class="pl-c-field__control"
type="search"
placeholder="Search accounts..."
/>
</label>
<button class="pl-c-button" data-variant="ghost">Filter</button>
</div>
<div class="pl-c-toolbar__group">
<button class="pl-c-button" data-variant="secondary">Export</button>
<button class="pl-c-button" data-variant="primary">New account</button>
</div>
</div>
<div class="pl-c-table-wrap" style="border-inline: 0; border-radius: 0;">
<table class="pl-c-table" style="--pl-c-table-min: 42rem;">
<thead>
<tr>
<th class="pl-c-table__check">
<input type="checkbox" aria-label="Select all" />
</th>
<th class="pl-c-table__sticky-col" data-sort="asc">
Account <span class="pl-c-table__sort-icon"></span>
</th>
<th>Status</th>
<th data-align="end">Seats</th>
<th data-align="end" data-sort="desc">
MRR <span class="pl-c-table__sort-icon"></span>
</th>
</tr>
</thead>
<tbody>
<tr aria-selected="true">
<td class="pl-c-table__check">
<input type="checkbox" checked aria-label="Select Acme" />
</td>
<td class="pl-c-table__sticky-col">Acme Ops</td>
<td><span class="pl-c-badge" data-tone="success">Active</span></td>
<td data-align="end">48</td>
<td data-align="end">$2,400</td>
</tr>
</tbody>
</table>
</div>
<div
class="pl-c-toolbar"
style="border-block-end: 0; border-block-start: 1px solid var(--pl-color-border);"
>
<div class="pl-c-toolbar__group">
<span class="pl-u-muted-sm">Showing 1-10 of 247</span>
</div>
<div class="pl-c-toolbar__group">
<nav class="pl-c-pagination" aria-label="Accounts pagination">
<button class="pl-c-pagination__nav" disabled>Prev</button>
<button class="pl-c-pagination__page" aria-current="page">1</button>
<button class="pl-c-pagination__page">2</button>
<button class="pl-c-pagination__nav">Next</button>
</nav>
</div>
</div>
</section>
Bulk Selection Actions Recipe
When table rows are selected, replace generic table actions with a scoped selection toolbar. Keep the selected count in a polite live region, provide a clear-selection action, and leave the table as the source of selection state.
<div class="pl-c-toolbar" role="region" aria-label="Selected account actions">
<div class="pl-c-toolbar__group">
<strong aria-live="polite">3 accounts selected</strong>
<button class="pl-c-button" data-variant="ghost" type="button">
Clear selection
</button>
</div>
<div class="pl-c-toolbar__group">
<button class="pl-c-button" data-variant="secondary" type="button">
Export
</button>
<button class="pl-c-button" data-variant="primary" type="button">
Assign owner
</button>
</div>
</div>
Application code owns the selected row IDs and keeps each checkbox,
aria-selected row state, count, and available actions synchronized. Do not
make the whole toolbar a live region; only announce the short count so focus
and button labels are not repeatedly re-read.
Inline Edit Recipe
Use inline edit for short, familiar values in tables, description lists, and inspectors. Keep a read view and a real form as sibling states so server-rendered fragments, client components, and hypermedia swaps can use the same HTML contract.
<div data-inline-edit>
<div data-inline-edit-read>
<span id="workspace-name-value">Acme Operations</span>
<button
class="pl-c-button"
data-variant="ghost"
type="button"
aria-controls="workspace-name-form"
>
Edit workspace name
</button>
</div>
<form id="workspace-name-form" class="pl-c-form" data-inline-edit-form hidden>
<label class="pl-c-field">
<span class="pl-c-field__label">Workspace name</span>
<input
class="pl-c-field__control"
name="workspace"
value="Acme Operations"
/>
</label>
<div class="pl-c-form__actions">
<button class="pl-c-button" data-variant="ghost" type="button">
Cancel
</button>
<button class="pl-c-button" data-variant="primary" type="submit">
Save
</button>
</div>
</form>
</div>
On edit, reveal the form and move focus to its control. On cancel, restore the
original value and focus to the edit button. On success, replace the read value,
hide the form, return focus, and announce the saved result with a nearby
role="status". On validation failure, keep the form open and use the standard
field aria-invalid and aria-describedby contract.
Settings Page Recipe
Use .pl-l-sidebar for local navigation plus a flexible settings panel. Use tabs
for major sections and .pl-c-form for real form layout.
<div class="pl-l-container">
<div class="pl-l-sidebar" style="--pl-l-sidebar-size: 14rem;">
<nav class="pl-c-nav" aria-label="Settings sections">
<a class="pl-c-nav__item" href="#profile" aria-current="page">
<span class="pl-c-nav__label">Profile</span>
</a>
<a class="pl-c-nav__item" href="#billing">
<span class="pl-c-nav__label">Billing</span>
</a>
</nav>
<section class="pl-c-card">
<div class="pl-c-card__body">
<div class="pl-c-tabs">
<div
class="pl-c-tabs__list"
role="tablist"
aria-label="Settings tabs"
>
<button class="pl-c-tabs__tab" role="tab" aria-selected="true">
General
</button>
<button
class="pl-c-tabs__tab"
role="tab"
aria-selected="false"
tabindex="-1"
>
Security
</button>
</div>
<div class="pl-c-tabs__panel" role="tabpanel">
<form class="pl-c-form">
<div class="pl-c-form__grid">
<label class="pl-c-field">
<span class="pl-c-field__label">Workspace name</span>
<input class="pl-c-field__control" value="Acme Ops" />
</label>
<div class="pl-c-field">
<label class="pl-c-field__label" for="workspace-slug"
>Workspace URL</label
>
<div class="pl-c-input-group">
<input
id="workspace-slug"
class="pl-c-field__control"
value="acme-ops"
/>
<span class="pl-c-input-group__addon">.porchlight.app</span>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</div>
</div>
Dense Admin View Recipe
Use data-pl-density="dense" for screens where scan speed matters more than
breathing room. Pair it with compact copy, truncation, numeric alignment, and
real table semantics.
<main class="pl-l-app-shell__main" data-pl-density="dense">
<div
class="pl-l-container pl-l-stack"
style="--pl-l-stack-gap: var(--pl-space-4);"
>
<div class="pl-c-page-header">
<div class="pl-c-page-header__heading">
<h1 class="pl-c-page-header__title">Event queue</h1>
<span class="pl-c-page-header__subtitle"
>1,248 events, 36 critical</span
>
</div>
<div class="pl-c-page-header__actions">
<button class="pl-c-button" data-variant="secondary">
Acknowledge
</button>
</div>
</div>
<section class="pl-c-card" data-surface="app">
<div class="pl-c-toolbar">
<div class="pl-c-toolbar__group">
<span class="pl-u-muted-sm">Filtered to production</span>
</div>
<div class="pl-c-toolbar__group">
<button class="pl-c-button" data-variant="ghost">Refresh</button>
</div>
</div>
<div
class="pl-c-table-wrap"
data-pl-density="dense"
style="border-inline: 0; border-radius: 0;"
>
<table class="pl-c-table" style="--pl-c-table-min: 54rem;">
<thead>
<tr>
<th>Time</th>
<th>Host</th>
<th>Message</th>
<th data-align="end">Score</th>
</tr>
</thead>
<tbody>
<tr>
<td><time>10:42:18</time></td>
<td><code>api-04</code></td>
<td class="pl-u-truncate" style="max-inline-size: 22rem;">
Unexpected auth spike from edge region
</td>
<td data-align="end">98</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</main>