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-gapor--pl-l-grid-min) so you can tune specific instances directly in HTML. - Container queries: Primitives like
.pl-l-sidebaradapt 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-sidebarinside an element withcontainer-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 from16remto3.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>