GuidePorchlight CSS

Composition Recipes

Model-friendly recipes for composing Porchlight app shells, dashboards, data regions, settings pages, and dense admin views.

51 components9 patterns46 stable5 experimental

Command

Search Porchlight

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-4 over 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-header inside padded content and .pl-c-toolbar on 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-card elements 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-only for 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>

Useful references