Theming
Porchlight is fully token-driven. Every color, spacing value, font size, radius, and motion duration is a CSS custom property you can override. This guide covers the token architecture, how to create a brand theme, and how the light/dark system works.
Token hierarchy
Tokens are split into two layers:
| Layer | Prefix | Purpose |
|---|---|---|
Primitive (02-tokens.css) | --pl-* | Raw design scales: font sizes, spacing steps, OKLCH color ramp, radii, motion, shadows |
Semantic (02-tokens.css) | --pl-color-* | Foreground/background pairs: bg, surface, text, border, accent, danger-*, success-*, warning-* |
| Component-local | --c-* | Per-component tokens defined inside @scope (e.g., --c-alert-tone) |
Semantic tokens use light-dark() so they automatically adapt to the
user’s color scheme. Override primitives to retheme the entire system;
override semantic tokens to fine-tune specific roles.
Light and dark
Porchlight uses the CSS color-scheme property to drive light-dark()
color pairs. Two ways to control the active scheme:
System preference (default)
Omit the data-theme attribute. The browser follows the OS setting:
<html>
<!-- follows prefers-color-scheme -->
</html>
Explicit theme
Set data-theme on the root element:
<html data-theme="dark">
<!-- always dark -->
</html>
Valid values: light, dark, or omit for system.
Creating a brand theme
Override the accent color pair to instantly rebrand. Both the accent and its text color need to meet WCAG-AA contrast (4.5:1 minimum):
@layer app {
:root {
/* Your brand color in OKLCH */
--pl-color-accent: oklch(55% 0.22 290deg);
--pl-color-accent-text: oklch(98% 0.01 290deg);
/* The accent ramp is derived from these via color-mix().
Override individual steps if you need full control: */
/* --pl-color-accent-300: oklch(...); */
/* --pl-color-accent-700: oklch(...); */
}
/* Dark theme brand override */
:root[data-theme="dark"] {
--pl-color-accent: oklch(70% 0.18 290deg);
--pl-color-accent-text: oklch(20% 0.05 290deg);
}
}
To go deeper, override the semantic surface/text tokens:
@layer app {
:root {
--pl-color-bg: oklch(99% 0.005 260deg);
--pl-color-surface: oklch(97% 0.008 260deg);
--pl-color-text: oklch(25% 0.02 260deg);
}
}
Advanced: full brand ramp
The accent color is derived from a 9-step brand ramp:
--pl-brand-1 (lightest) → --pl-brand-9 (darkest)
--pl-color-accent = light-dark(var(--pl-brand-7), var(--pl-brand-4))
To retheme with your brand hue, override the ramp steps. Each step varies lightness while keeping the same hue and chroma direction:
@layer app {
:root {
/* Target red: hue 25° */
--pl-brand-1: oklch(96% 0.02 25deg);
--pl-brand-2: oklch(88% 0.05 25deg);
--pl-brand-3: oklch(79% 0.09 25deg);
--pl-brand-4: oklch(69% 0.17 25deg); /* dark-mode accent */
--pl-brand-5: oklch(62% 0.19 25deg);
--pl-brand-6: oklch(55% 0.2 25deg);
--pl-brand-7: oklch(47% 0.21 25deg); /* light-mode accent */
--pl-brand-8: oklch(38% 0.16 25deg);
--pl-brand-9: oklch(29% 0.1 25deg);
}
}
Three tokens to update together
When you change the brand hue, three tokens need attention:
-
Brand ramp (
--pl-brand-1through--pl-brand-9) — sets the accent. -
Focus ring (
--pl-focus-color) — a separate token, not derived from the brand ramp. It defaults to hue82°(amber). Override it to match:--pl-focus-color: light-dark(oklch(50% 0.18 25deg), oklch(70% 0.15 25deg)); -
Danger hue (
--pl-color-danger) — defaults tooklch(60% 0.22 25deg). If your brand is red/orange (hue ~25°), the danger state will visually collide with primary actions. Shift danger to a different hue to maintain semantic clarity:--pl-color-danger: oklch(57% 0.23 12deg); --pl-color-danger-text: light-dark( oklch(36% 0.2 12deg), oklch(90% 0.08 12deg) );
Corporate vs. consumer feel
The large-surface radius tokens control how “rounded” the framework feels:
@layer app {
:root {
/* Corporate / enterprise: tighter corners */
--pl-radius-xl: 0.5rem;
--pl-radius-2xl: 0.75rem;
/* Consumer / playful: more rounded */
/* --pl-radius-xl: 1.5rem; */
/* --pl-radius-2xl: 2rem; */
}
}
These drive card corners, dialog radius, table wrap radius, and toast radius.
Density modes
Four density presets control component geometry:
| Tier | Control height | Best for |
|---|---|---|
compact | 2rem (32px) | Admin panels, settings |
comfortable | 2.5rem (40px) | Default — most SaaS apps |
touch | 2.75rem (44px) | Mobile, tablets, kiosks |
dense | 1.75rem (28px) | SIEM, analytics, log viewers |
<div data-density="compact">Tighter controls</div>
<div data-density="comfortable">Default</div>
<div data-density="touch">Larger touch targets</div>
<div data-density="dense">Maximum information density</div>
Set on any ancestor. compact, comfortable, and touch change only
--pl-control-block-size, --pl-control-padding-inline, and
--pl-control-gap — type, spacing, and radius are unchanged.
The dense tier goes further: it also compresses the --pl-space-* scale
(~30% reduction) and small text sizes (--pl-text-sm, --pl-text-xs) for
maximum information density. See the
Data-dense mode reference for details.
Reduced motion
Porchlight respects prefers-reduced-motion: reduce automatically:
--pl-motion-scaledrops to0, which zeroes all duration tokens- A universal
transition-duration: 1ms !importantclamp fires on every element, guaranteeing no stray transitions outlast the preference - Scroll behavior reverts to
auto
You don’t need to do anything. This is one of the few places !important
is used (it’s an accessibility guarantee).
Contrast preference
When the browser reports prefers-contrast: more, Porchlight strengthens
the structural tokens components already use:
--pl-color-borderbecomes more visible--pl-color-surface-2separates stronger from the page background--pl-focus-sizeincreases to3px--pl-focus-glow-opacitymakes field glow rings easier to see
This is a token-level adjustment, not a separate component mode. Apps can still override these tokens in their own layer when a brand needs different contrast behavior.
Forced colors (High Contrast)
Under Windows High Contrast mode, all semantic tokens remap to system
colors (Canvas, CanvasText, ButtonBorder, Highlight). The UI
remains legible without any action from you.
Component-level theming
Each component defines --c-* tokens inside its @scope. Override these
to fine-tune a specific component without touching the global palette:
@layer app {
.c-card {
--c-card-pad: var(--pl-space-6);
--c-card-radius: var(--pl-radius-xl);
}
}
Token reference
For the complete list of tokens with live values, see the token reference pages.