Theming
Lattice renders with a single set of semantic design tokens — CSS custom properties prefixed
with --lt-. Theme it server-side with the PHP Theme builder, or override the tokens directly
in plain CSS — the defaults live in a cascade layer, so any override you
write wins regardless of stylesheet order.
Tokens are global and shared across components. The Style tab on each component example lists the tokens that example uses in its initial rendered state. Tokens that only appear in interactive or overlay content — a dropdown panel, a dialog, a popover that opens on demand — are not captured there; this page is the complete reference.
Server-side theming (PHP)
Section titled “Server-side theming (PHP)”Build a Theme in PHP and register it with the Lattice facade, typically in a service
provider’s boot() method:
use Lattice\Core\Facades\Lattice;use Lattice\Theme\Theme;
Lattice::theme( Theme::make() ->primary('#6366f1') ->radius('0.75rem') ->dark(fn (Theme $theme): Theme => $theme->primary('#818cf8')),);Every colour has its own method. The interactive groups — primary(), secondary(), danger(),
success(), warning(), info() — take the base colour plus optional foreground, hover, and
active arguments. Hover and active left unset are derived from the base by the stylesheet
(darker in light mode, lighter in dark mode), and the focus ring and accent tint take their hue
from the primary — so one primary() call restyles the whole interactive family coherently:
Theme::make() ->primary('#6366f1') // hover + active follow ->danger('#e11d48', foreground: '#fff', hover: '#be123c'); // or pin them yourselfThe surface pairs — background(), surface(), popover(), muted(), accent(),
disabled() — take the colour and an optional foreground. border(), input(), ring(), and
overlay() take a single colour, and chart([...]) accepts up to eight series colours. Scalars
have their own methods — radius(), ringWidth(), ringOffset(), fontSans(), fontMono(),
fontDisplay() — or fall back to set('token', $value) for anything else; bare names get the
--lt- prefix. Any valid CSS colour is accepted — hex, rgb(), oklch(), and so on.
Dark values
Section titled “Dark values”dark() takes a closure that builds the dark-mode overrides from a fresh Theme. For a single
colour, Color::hex(...)->dark(...) is shorter — the counterpart lands in the dark scope
automatically:
use Lattice\Core\Color;
Theme::make()->primary(Color::hex('#6366f1')->dark('#818cf8'));Swatches
Section titled “Swatches”Swatch presets bundle a tuned base, its dark-mode counterpart, and a contrast-safe foreground —
a complete brand colour in one argument:
use Lattice\Theme\Swatch;
Theme::make()->primary(Swatch::Indigo);Available: Teal, Emerald, Green, Blue, Indigo, Violet, Rose, Red, Orange,
Amber.
Colour groups
Section titled “Colour groups”A ColorGroup carries a base colour and its companions as one portable value — define your brand
once and reuse it, or store it per tenant and rebuild it from an array:
use Lattice\Theme\ColorGroup;
$brand = ColorGroup::make(Color::hex('#4f46e5')->dark('#818cf8')) ->foreground('#ffffff') ->hover('#4338ca');
Theme::make()->primary($brand);
// per-tenant branding from storageLattice::theme(fn (): Theme => Theme::make()->primary( ColorGroup::fromArray(tenant()->brand),));A Swatch is simply a preset ColorGroup (Swatch::Indigo->group()), and explicit method
arguments win over the group’s slots.
Pass a closure instead of a Theme to Lattice::theme() to resolve it per request — for example
per-tenant branding:
Lattice::theme(fn (): Theme => Theme::make()->primary(tenant()->brand_color));Prefer this closure form for per-request or per-tenant themes: a concrete Theme registered
directly persists for the life of the worker process, so under Octane it would leak the first
request’s theme into every later one.
Render it by adding @latticeTheme to your root Blade layout’s <head>:
<head> @latticeTheme @vite(['resources/css/app.css', 'resources/js/app.tsx']) @inertiaHead</head>It emits a <style id="lattice-theme"> block server-side, so the correct theme is present on
first paint — no flash of the default theme before JS runs.
Dark mode
Section titled “Dark mode”Every token ships a light and a dark value (the Light and Dark columns below). Dark mode is
driven by a dark class on the <html> element: when it is present, the .dark token values
take over. You theme dark mode the same way as light — via Theme::dark() on the server, or by
overriding --lt-* tokens under .dark in your CSS.
Lattice manages the class for you through its appearance helpers, exported from the package:
initializeAppearance()— call once before your app mounts (e.g. in your entry file). It reads the stored preference (localStoragekeyappearance, defaultsystem), applies thedarkclass andcolor-scheme, and keeps them in sync when the OS preference changes.useAppearance()— a hook returning{ appearance, resolvedAppearance, updateAppearance }.appearanceis the chosen mode ("light" | "dark" | "system"),resolvedAppearanceis the effective"light" | "dark"after resolvingsystem, andupdateAppearance(mode)persists the choice (tolocalStorageand a cookie) and applies it immediately. Build your own theme switcher on top of it.
import { initializeAppearance, useAppearance } from "@lattice-php/lattice";
initializeAppearance();
function AppearanceToggle() { const { appearance, updateAppearance } = useAppearance();
return ( <button onClick={() => updateAppearance(appearance === "dark" ? "light" : "dark")}> Toggle theme </button> );}Token reference
Section titled “Token reference”Colors
| Token | Light | Dark |
|---|---|---|
--lt-bg | oklch(0.976 0.003 210) | oklch(0.155 0.008 225) |
--lt-fg | oklch(0.18 0.01 220) | oklch(0.955 0.005 210) |
--lt-surface | oklch(1 0 0) | oklch(0.195 0.01 225) |
--lt-surface-fg | oklch(0.18 0.01 220) | oklch(0.955 0.005 210) |
--lt-popover | oklch(1 0 0) | oklch(0.225 0.012 225) |
--lt-popover-fg | oklch(0.18 0.01 220) | oklch(0.955 0.005 210) |
--lt-primary | oklch(0.51 0.12 195) | oklch(0.75 0.115 190) |
--lt-primary-fg | oklch(0.985 0 0) | oklch(0.17 0.03 195) |
--lt-secondary | oklch(0.955 0.005 210) | oklch(0.27 0.012 225) |
--lt-secondary-fg | oklch(0.25 0.012 220) | oklch(0.955 0.005 210) |
--lt-muted | oklch(0.955 0.004 210) | oklch(0.26 0.01 225) |
--lt-muted-fg | oklch(0.52 0.015 220) | oklch(0.72 0.012 220) |
--lt-accent | oklch(from var(--lt-primary) 0.95 0.024 h) | oklch(from var(--lt-primary) 0.29 0.03 h) |
--lt-accent-fg | oklch(from var(--lt-primary) 0.36 0.085 h) | oklch(from var(--lt-primary) 0.97 0.005 h) |
--lt-danger | oklch(0.55 0.2 26) | oklch(0.55 0.18 25) |
--lt-danger-fg | oklch(0.985 0 0) | oklch(0.985 0 0) |
--lt-border | oklch(0.915 0.005 210) | oklch(0.285 0.012 225) |
--lt-input | oklch(0.88 0.007 210) | oklch(0.33 0.014 225) |
--lt-ring | oklch(from var(--lt-primary) 0.7 0.1 h) | oklch(from var(--lt-primary) 0.6 0.1 h) |
--lt-overlay | oklch(0.15 0.02 225 / 0.5) | oklch(0.08 0.01 225 / 0.65) |
--lt-warning | oklch(0.83 0.15 85) | oklch(0.75 0.14 82) |
--lt-warning-fg | oklch(0.28 0.06 70) | oklch(0.28 0.06 70) |
--lt-success | oklch(0.56 0.13 158) | oklch(0.72 0.14 160) |
--lt-success-fg | oklch(0.985 0 0) | oklch(0.2 0.04 160) |
--lt-info | oklch(0.55 0.13 245) | oklch(0.72 0.13 242) |
--lt-info-fg | oklch(0.985 0 0) | oklch(0.2 0.04 242) |
--lt-primary-hover | oklch(from var(--lt-primary) calc(l - 0.05) c h) | oklch(from var(--lt-primary) calc(l + 0.04) c h) |
--lt-primary-active | oklch(from var(--lt-primary) calc(l - 0.1) c h) | oklch(from var(--lt-primary) calc(l + 0.08) c h) |
--lt-danger-hover | oklch(from var(--lt-danger) calc(l - 0.05) c h) | oklch(from var(--lt-danger) calc(l + 0.04) c h) |
--lt-danger-active | oklch(from var(--lt-danger) calc(l - 0.1) c h) | oklch(from var(--lt-danger) calc(l + 0.08) c h) |
--lt-success-hover | oklch(from var(--lt-success) calc(l - 0.05) c h) | oklch(from var(--lt-success) calc(l + 0.04) c h) |
--lt-success-active | oklch(from var(--lt-success) calc(l - 0.1) c h) | oklch(from var(--lt-success) calc(l + 0.08) c h) |
--lt-info-hover | oklch(from var(--lt-info) calc(l - 0.05) c h) | oklch(from var(--lt-info) calc(l + 0.04) c h) |
--lt-info-active | oklch(from var(--lt-info) calc(l - 0.1) c h) | oklch(from var(--lt-info) calc(l + 0.08) c h) |
--lt-warning-hover | oklch(from var(--lt-warning) calc(l - 0.05) c h) | oklch(from var(--lt-warning) calc(l + 0.04) c h) |
--lt-warning-active | oklch(from var(--lt-warning) calc(l - 0.1) c h) | oklch(from var(--lt-warning) calc(l + 0.08) c h) |
--lt-secondary-hover | oklch(from var(--lt-secondary) calc(l - 0.03) c h) | oklch(from var(--lt-secondary) calc(l + 0.04) c h) |
--lt-secondary-active | oklch(from var(--lt-secondary) calc(l - 0.06) c h) | oklch(from var(--lt-secondary) calc(l + 0.08) c h) |
--lt-disabled | oklch(0.945 0.003 210) | oklch(0.28 0.01 225) |
--lt-disabled-fg | oklch(0.68 0.008 220) | oklch(0.56 0.012 225) |
--lt-ring-width | 3px | 3px |
--lt-ring-offset | 0px | 0px |
--lt-font-sans | "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji" | "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji" |
--lt-font-mono | ui-monospace, "SFMono-Regular", "Menlo", monospace | ui-monospace, "SFMono-Regular", "Menlo", monospace |
--lt-font-display | var(--lt-font-sans) | var(--lt-font-sans) |
--lt-shadow-inner | inset 0 1px 2px 0 oklch(0.2 0.02 225 / 0.06) | inset 0 1px 2px 0 oklch(0.2 0.02 225 / 0.06) |
--lt-chart-1 | oklch(0.55 0.12 195) | oklch(0.72 0.12 190) |
--lt-chart-2 | oklch(0.58 0.14 250) | oklch(0.7 0.12 250) |
--lt-chart-3 | oklch(0.68 0.14 60) | oklch(0.76 0.14 60) |
--lt-chart-4 | oklch(0.58 0.19 305) | oklch(0.7 0.16 305) |
--lt-chart-5 | oklch(0.6 0.16 20) | oklch(0.7 0.16 20) |
--lt-chart-6 | oklch(0.62 0.13 150) | oklch(0.74 0.12 150) |
--lt-chart-7 | oklch(0.6 0.13 100) | oklch(0.72 0.12 100) |
--lt-chart-8 | oklch(0.55 0.05 280) | oklch(0.68 0.05 280) |
--lt-icon-xs | 0.75rem | 0.75rem |
--lt-icon-sm | 0.875rem | 0.875rem |
--lt-icon-md | 1rem | 1rem |
--lt-icon-lg | 1.25rem | 1.25rem |
--lt-icon-xl | 1.5rem | 1.5rem |
--lt-icon-2xl | 2rem | 2rem |
--lt-icon-3xl | 2.5rem | 2.5rem |
--lt-icon-4xl | 3rem | 3rem |
--lt-control-h-sm | 2rem | 2rem |
--lt-control-h-md | 2.25rem | 2.25rem |
--lt-control-h-lg | 2.5rem | 2.5rem |
--lt-input-font-size-mobile | 1rem | 1rem |
--lt-table-cell-x | calc(var(--spacing) * 4) | calc(var(--spacing) * 4) |
--lt-table-cell-y | calc(var(--spacing) * 4) | calc(var(--spacing) * 4) |
--lt-font-weight-normal | 400 | 400 |
--lt-font-weight-medium | 500 | 500 |
--lt-font-weight-semibold | 600 | 600 |
--lt-font-weight-bold | 700 | 700 |
--lt-shadow-xs | 0 1px 1px 0 oklch(0.2 0.02 225 / 0.05) | 0 1px 1px 0 oklch(0.2 0.02 225 / 0.05) |
--lt-shadow-sm | 0 1px 2px 0 oklch(0.2 0.02 225 / 0.06) | 0 1px 2px 0 oklch(0.2 0.02 225 / 0.06) |
--lt-shadow-md | 0 2px 4px -1px oklch(0.2 0.02 225 / 0.09), 0 1px 2px -1px oklch(0.2 0.02 225 / 0.06) | 0 2px 4px -1px oklch(0.2 0.02 225 / 0.09), 0 1px 2px -1px oklch(0.2 0.02 225 / 0.06) |
--lt-shadow-lg | 0 8px 16px -4px oklch(0.2 0.02 225 / 0.13), 0 2px 4px -2px oklch(0.2 0.02 225 / 0.08) | 0 8px 16px -4px oklch(0.2 0.02 225 / 0.13), 0 2px 4px -2px oklch(0.2 0.02 225 / 0.08) |
--lt-z-dropdown | 1000 | 1000 |
--lt-z-sticky | 1100 | 1100 |
--lt-z-overlay | 1200 | 1200 |
--lt-z-modal | 1300 | 1300 |
--lt-z-popover | 1400 | 1400 |
--lt-z-toast | 1500 | 1500 |
--lt-duration-fast | 120ms | 120ms |
--lt-duration-base | 160ms | 160ms |
--lt-duration-slow | 240ms | 240ms |
--lt-sidebar-w | 16rem | 16rem |
--lt-sidebar-w-collapsed | 4rem | 4rem |
--lt-container-max | 80rem | 80rem |
--lt-topbar-h | 3.5rem | 3.5rem |
--lt-sticky-offset | 0px | 0px |
--lt-gutter | calc(var(--spacing) * 6) | calc(var(--spacing) * 6) |
--lt-table-pinned-bg | var(--lt-bg) | var(--lt-bg) |
--lt-table-pinned-muted-bg | color-mix(in oklab, var(--lt-muted) 50%, var(--lt-bg)) | color-mix(in oklab, var(--lt-muted) 50%, var(--lt-bg)) |
--lt-table-pinned-stripe-bg | color-mix(in oklab, var(--lt-muted) 30%, var(--lt-bg)) | color-mix(in oklab, var(--lt-muted) 30%, var(--lt-bg)) |
--lt-table-pinned-hover-bg | color-mix(in oklab, var(--lt-muted) 50%, var(--lt-bg)) | color-mix(in oklab, var(--lt-muted) 50%, var(--lt-bg)) |
Radius
| Token | Light | Dark |
|---|---|---|
--lt-radius | 0.5rem | 0.5rem |
--lt-radius-sm | calc(var(--lt-radius) - 0.125rem) | calc(var(--lt-radius) - 0.125rem) |
--lt-radius-xs | calc(var(--lt-radius) - 0.25rem) | calc(var(--lt-radius) - 0.25rem) |
--lt-radius-lg | calc(var(--lt-radius) + 0.25rem) | calc(var(--lt-radius) + 0.25rem) |
--lt-radius-xl | calc(var(--lt-radius) + 0.5rem) | calc(var(--lt-radius) + 0.5rem) |
--lt-radius-full | 9999px | 9999px |
Plain CSS overrides
Section titled “Plain CSS overrides”The defaults ship inside a @layer lattice cascade layer, and layered styles always lose to
unlayered ones — so overriding a token is a plain declaration in your own stylesheet, no
specificity tricks and no import-order care needed:
:root { --lt-primary: oklch(0.55 0.2 265); --lt-radius: 0.75rem;}
.dark { --lt-primary: oklch(0.7 0.18 265);}This is exactly the output Theme::toCss() generates. Reach for it when a design-token pipeline
already owns your CSS, or when a one-off override doesn’t warrant a server round trip — otherwise
prefer server-side theming.