GuidePorchlight CSS

Layout & Page Building

Use Porchlight's layout primitives, app shell, and container-query sidebar to compose responsive SaaS pages.

51 components9 patterns46 stable5 experimental

Command

Search Porchlight

Layout & Page Building

Porchlight provides a collection of lightweight layout primitives instead of rigid page grids. They are designed to manage the regions around components, allowing you to compose full pages out of custom components and utilities.

All layout primitives live in @layer porchlight.layout and share a few core design principles:

  • Zero specificity: They are wrapped in :where() selectors, making them easy to override in your application CSS.
  • Token-driven spacing: Spacing gaps default to standard token values (e.g. var(--pl-space-4)).
  • Tunable variables: Each primitive exposes one or more --pl-l-* CSS custom properties (like --pl-l-stack-gap or --pl-l-grid-min) so you can tune specific instances directly in HTML.
  • Container queries: Primitives like .pl-l-sidebar adapt to the size of their containing panel rather than the browser window, ensuring responsiveness when nested inside complex dashboards.

Primitive Reference

1. Stack (.pl-l-stack)

Use for vertical flow, such as form groups, card contents, settings sections, or stacked list items.

<div class="pl-l-stack" style="--pl-l-stack-gap: var(--pl-space-3);">
  <label class="pl-c-field">...</label>
  <label class="pl-c-field">...</label>
</div>
Token Description Default
--pl-l-stack-gap Vertical gap between items var(--pl-space-4)

The stack owns gaps between children; it does not add outer padding or remove child margins. Put sibling cards in a stack/grid, then let each card own its internal padding. If a child already has a bottom margin (for example, .pl-c-page-header), clear it in your app layer when the stack owns separation:

@layer app {
  .workspace > .pl-c-page-header {
    margin-block-end: 0;
  }
}

Keep that reset scoped to your composition. Docs-only preview styles are not included in the published package.

2. Cluster (.pl-l-cluster)

Use for horizontal wrapping groups where elements flow naturally. Ideal for toolbar controls, filter lists, chip groups, or buttons in an action row.

<div class="pl-l-cluster" style="--pl-l-cluster-justify: space-between;">
  <div>Status filters...</div>
  <button class="pl-c-button">Action</button>
</div>
Token Description Default
--pl-l-cluster-gap Spacing between items var(--pl-space-3)
--pl-l-cluster-align Vertical alignment (align-items) center
--pl-l-cluster-justify Horizontal alignment (justify-content) flex-start

3. Grid (.pl-l-grid)

An auto-fitting grid that wraps columns automatically when they fall below a minimum width. Perfect for dashboard KPI grids, search results, or card directories.

<div class="pl-l-grid" style="--pl-l-grid-min: 18rem;">
  <div class="pl-c-card">Card A</div>
  <div class="pl-c-card">Card B</div>
  <div class="pl-c-card">Card C</div>
</div>
Token Description Default
--pl-l-grid-gap Grid gap var(--pl-space-4)
--pl-l-grid-min Minimum width before columns wrap 16rem

4. Sidebar (.pl-l-sidebar)

A two-column layout consisting of a fixed-width column (typically a sidebar navigation or filter panel) and a flexible main column. It collapses to a single column when the viewport or parent container is narrower than 48rem.

[!NOTE] To trigger container-query collapse, wrap .pl-l-sidebar inside an element with container-type: inline-size, or nest it directly within .pl-l-app-shell__main (which already defines a query container).

<div class="pl-l-sidebar" style="--pl-l-sidebar-size: 14rem;">
  <aside>Sidebar Nav</aside>
  <main>Main Content</main>
</div>
Token Description Default
--pl-l-sidebar-gap Column gap var(--pl-space-4)
--pl-l-sidebar-size Width of the sidebar column 16rem

5. Scroll Area (.pl-l-scroll-area)

An independent scroll container that contains overscroll to prevent scroll chaining to the main page. It uses scrollbar-gutter: stable to prevent layout shifts when scrollbars appear or disappear.

<div class="pl-l-scroll-area" style="block-size: 20rem;">
  <!-- long content -->
</div>

6. Columns (.pl-l-columns)

A CSS multi-column layout for card walls or image galleries. Content flows down each column before moving to the next; this is not row-major CSS Grid masonry. Use a grid for ordered content that people need to scan across rows.

<div class="pl-l-columns" style="--pl-l-columns-width: 16rem;">
  <div class="pl-c-card">Tall item</div>
  <div class="pl-c-card">Short item</div>
</div>
Token Description Default
--pl-l-columns-gap Gap between columns and cards var(--pl-space-4)
--pl-l-columns-width Target column width 16rem
--pl-l-columns-count Maximum column count (optional) auto

7. Container (.pl-l-container)

A centered, responsive max-width wrapper designed for dashboards, list views, and settings forms.

<div class="pl-l-container">
  <h1>Dashboard</h1>
  <!-- grid etc. -->
</div>
Token Description Default
--pl-l-container-max Maximum inline width 80rem
--pl-l-container-pad Space reserved on each inline side var(--pl-space-4)

8. Inset (.pl-l-inset)

A centered column designed to constrain content inside a wider, full-bleed element (such as forms inside a hero banner or captions in a video section).

<div class="bleed-section">
  <div class="pl-l-inset">
    <h2>Constrained Form</h2>
  </div>
</div>
Token Description Default
--pl-l-inset-max Maximum inline width 48rem

The inset only limits width; it does not supply gutters or vertical spacing. Add token-based padding to the full-bleed parent so content does not touch the viewport edge on mobile.

9. Page (.pl-l-page)

A centered container with inline gutters and an 88rem default maximum. For long-form prose, set a narrower measure such as --pl-l-page-max: 68ch.

Token Description Default
--pl-l-page-max Maximum inline width 88rem

App Shell Layout

The .pl-l-app-shell coordinates the main layout of a SaaS or dashboard application. It provides:

  • A sticky topbar (.pl-l-app-shell__topbar).
  • A sidebar navigation rail (.pl-l-app-shell__sidebar) supporting manual collapse toggles.
  • A scrolling main window (.pl-l-app-shell__main) configured as a container query context.

The shell fills 100dvb and scrolls its main region. When embedding it below an existing site header, size the shell to the remaining space and account for that header in sticky positioning. Use one main landmark; use a div for an embedded work region if the host page already has main. Provide a mobile navigation control before hiding the desktop rail.

Responsive Behavior

  • Desktop (60rem+): The topbar spans the full width; sidebar and main sit below it.
  • Tablet / Mobile (< 60rem): The sidebar is hidden (display: none), prioritizing the work canvas.
  • Sidebar Collapse: data-sidebar="collapsed" changes the default rail width from 16rem to 3.5rem. Your app supplies icons, accessible labels, and label-hiding behavior.
<div class="pl-l-app-shell">
  <header class="pl-l-app-shell__topbar">Topbar Content</header>
  <aside class="pl-l-app-shell__sidebar" data-sidebar="collapsed">
    <!-- Icon Navigation -->
  </aside>
  <main class="pl-l-app-shell__main">
    <div class="pl-l-container">
      <!-- Page Content -->
    </div>
  </main>
</div>

Page-Building Examples

Example 1: SaaS Dashboard Page

A complete dashboard composed of a sticky topbar, sidebar rail, KPI grid, and a split layout for recent activity.

<div class="pl-l-app-shell">
  <!-- Sticky Header -->
  <header
    class="pl-l-app-shell__topbar pl-c-card"
    style="border-radius: 0; border-inline: 0;"
  >
    <div
      class="pl-l-cluster"
      style="--pl-l-cluster-justify: space-between; padding: var(--pl-space-3);"
    >
      <strong>Logo</strong>
      <div class="pl-l-cluster">
        <span>User Profile</span>
      </div>
    </div>
  </header>

  <!-- Sidebar Rail -->
  <aside
    class="pl-l-app-shell__sidebar"
    style="background: var(--pl-color-surface-2); border-inline-end: 1px solid var(--pl-color-border);"
  >
    <nav
      class="pl-c-nav pl-l-stack"
      aria-label="Main navigation"
      style="--pl-l-stack-gap: var(--pl-space-1); padding: var(--pl-space-3);"
    >
      <a class="pl-c-nav__item" href="#" aria-current="page">Dashboard</a>
      <a class="pl-c-nav__item" href="#">Team</a>
      <a class="pl-c-nav__item" href="#">Settings</a>
    </nav>
  </aside>

  <!-- Main Workspace -->
  <main class="pl-l-app-shell__main" style="padding-block: var(--pl-space-6);">
    <div
      class="pl-l-container pl-l-stack"
      style="--pl-l-stack-gap: var(--pl-space-6);"
    >
      <!-- Top Section -->
      <div class="pl-l-cluster" style="--pl-l-cluster-justify: space-between;">
        <h1>Dashboard</h1>
        <button class="pl-c-button" data-variant="primary">
          Create Project
        </button>
      </div>

      <!-- KPI Grid -->
      <div class="pl-l-grid" style="--pl-l-grid-min: 16rem;">
        <div class="pl-c-card">
          <div class="pl-c-card__body">Active Seats: 1,280</div>
        </div>
        <div class="pl-c-card">
          <div class="pl-c-card__body">MRR: $48.2k</div>
        </div>
        <div class="pl-c-card">
          <div class="pl-c-card__body">Open Tickets: 14</div>
        </div>
      </div>

      <!-- Main Columns -->
      <div class="pl-l-sidebar" style="--pl-l-sidebar-size: 20rem;">
        <!-- First, fixed-width column (inline start) -->
        <div class="pl-c-card">
          <div class="pl-c-card__header">
            <h3 class="pl-c-card__title">Activity Feed</h3>
          </div>
          <div
            class="pl-c-card__body pl-l-stack"
            style="--pl-l-stack-gap: var(--pl-space-3);"
          >
            <div>User created task A</div>
            <div>User merged branch B</div>
          </div>
        </div>

        <!-- Second, flexible column (inline end) -->
        <div class="pl-c-card">
          <div class="pl-c-card__header">
            <h3 class="pl-c-card__title">Active Issues</h3>
          </div>
          <div class="pl-c-card__body">
            <!-- Table component -->
          </div>
        </div>
      </div>
    </div>
  </main>
</div>

Example 2: Settings Form

A classic setting view showing a side menu and stacked inputs that adapt when screen width decreases.

<div class="pl-l-container" style="padding-block: var(--pl-space-6);">
  <div class="pl-l-sidebar" style="--pl-l-sidebar-size: 16rem;">
    <!-- Local Sub-navigation -->
    <nav class="pl-c-nav" aria-label="Settings navigation">
      <a class="pl-c-nav__item" href="#" aria-current="page"
        >General Settings</a
      >
      <a class="pl-c-nav__item" href="#">Security</a>
      <a class="pl-c-nav__item" href="#">Billing</a>
    </nav>

    <!-- Settings Content -->
    <form class="pl-c-card">
      <div class="pl-c-card__header">
        <h2 class="pl-c-card__title">Profile Settings</h2>
      </div>

      <div
        class="pl-c-card__body pl-l-stack"
        style="--pl-l-stack-gap: var(--pl-space-4);"
      >
        <label class="pl-c-field">
          <span class="pl-c-field__label">Full Name</span>
          <input
            class="pl-c-field__control"
            type="text"
            value="Jane Doe"
            required
          />
        </label>

        <label class="pl-c-field">
          <span class="pl-c-field__label">Email Address</span>
          <input
            class="pl-c-field__control"
            type="email"
            value="jane@example.com"
            required
          />
        </label>
      </div>

      <div
        class="pl-c-card__footer pl-l-cluster"
        style="--pl-l-cluster-justify: flex-end;"
      >
        <button class="pl-c-button" type="button">Cancel</button>
        <button class="pl-c-button" data-variant="primary" type="submit">
          Save
        </button>
      </div>
    </form>
  </div>
</div>