Skip to content

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.

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 yourself

The 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() 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'));

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.

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 storage
Lattice::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.

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 (localStorage key appearance, default system), applies the dark class and color-scheme, and keeps them in sync when the OS preference changes.
  • useAppearance() — a hook returning { appearance, resolvedAppearance, updateAppearance }. appearance is the chosen mode ("light" | "dark" | "system"), resolvedAppearance is the effective "light" | "dark" after resolving system, and updateAppearance(mode) persists the choice (to localStorage and 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>
);
}

Colors

TokenLightDark
--lt-bgoklch(0.976 0.003 210)oklch(0.155 0.008 225)
--lt-fgoklch(0.18 0.01 220)oklch(0.955 0.005 210)
--lt-surfaceoklch(1 0 0)oklch(0.195 0.01 225)
--lt-surface-fgoklch(0.18 0.01 220)oklch(0.955 0.005 210)
--lt-popoveroklch(1 0 0)oklch(0.225 0.012 225)
--lt-popover-fgoklch(0.18 0.01 220)oklch(0.955 0.005 210)
--lt-primaryoklch(0.51 0.12 195)oklch(0.75 0.115 190)
--lt-primary-fgoklch(0.985 0 0)oklch(0.17 0.03 195)
--lt-secondaryoklch(0.955 0.005 210)oklch(0.27 0.012 225)
--lt-secondary-fgoklch(0.25 0.012 220)oklch(0.955 0.005 210)
--lt-mutedoklch(0.955 0.004 210)oklch(0.26 0.01 225)
--lt-muted-fgoklch(0.52 0.015 220)oklch(0.72 0.012 220)
--lt-accentoklch(from var(--lt-primary) 0.95 0.024 h)oklch(from var(--lt-primary) 0.29 0.03 h)
--lt-accent-fgoklch(from var(--lt-primary) 0.36 0.085 h)oklch(from var(--lt-primary) 0.97 0.005 h)
--lt-dangeroklch(0.55 0.2 26)oklch(0.55 0.18 25)
--lt-danger-fgoklch(0.985 0 0)oklch(0.985 0 0)
--lt-borderoklch(0.915 0.005 210)oklch(0.285 0.012 225)
--lt-inputoklch(0.88 0.007 210)oklch(0.33 0.014 225)
--lt-ringoklch(from var(--lt-primary) 0.7 0.1 h)oklch(from var(--lt-primary) 0.6 0.1 h)
--lt-overlayoklch(0.15 0.02 225 / 0.5)oklch(0.08 0.01 225 / 0.65)
--lt-warningoklch(0.83 0.15 85)oklch(0.75 0.14 82)
--lt-warning-fgoklch(0.28 0.06 70)oklch(0.28 0.06 70)
--lt-successoklch(0.56 0.13 158)oklch(0.72 0.14 160)
--lt-success-fgoklch(0.985 0 0)oklch(0.2 0.04 160)
--lt-infooklch(0.55 0.13 245)oklch(0.72 0.13 242)
--lt-info-fgoklch(0.985 0 0)oklch(0.2 0.04 242)
--lt-primary-hoveroklch(from var(--lt-primary) calc(l - 0.05) c h)oklch(from var(--lt-primary) calc(l + 0.04) c h)
--lt-primary-activeoklch(from var(--lt-primary) calc(l - 0.1) c h)oklch(from var(--lt-primary) calc(l + 0.08) c h)
--lt-danger-hoveroklch(from var(--lt-danger) calc(l - 0.05) c h)oklch(from var(--lt-danger) calc(l + 0.04) c h)
--lt-danger-activeoklch(from var(--lt-danger) calc(l - 0.1) c h)oklch(from var(--lt-danger) calc(l + 0.08) c h)
--lt-success-hoveroklch(from var(--lt-success) calc(l - 0.05) c h)oklch(from var(--lt-success) calc(l + 0.04) c h)
--lt-success-activeoklch(from var(--lt-success) calc(l - 0.1) c h)oklch(from var(--lt-success) calc(l + 0.08) c h)
--lt-info-hoveroklch(from var(--lt-info) calc(l - 0.05) c h)oklch(from var(--lt-info) calc(l + 0.04) c h)
--lt-info-activeoklch(from var(--lt-info) calc(l - 0.1) c h)oklch(from var(--lt-info) calc(l + 0.08) c h)
--lt-warning-hoveroklch(from var(--lt-warning) calc(l - 0.05) c h)oklch(from var(--lt-warning) calc(l + 0.04) c h)
--lt-warning-activeoklch(from var(--lt-warning) calc(l - 0.1) c h)oklch(from var(--lt-warning) calc(l + 0.08) c h)
--lt-secondary-hoveroklch(from var(--lt-secondary) calc(l - 0.03) c h)oklch(from var(--lt-secondary) calc(l + 0.04) c h)
--lt-secondary-activeoklch(from var(--lt-secondary) calc(l - 0.06) c h)oklch(from var(--lt-secondary) calc(l + 0.08) c h)
--lt-disabledoklch(0.945 0.003 210)oklch(0.28 0.01 225)
--lt-disabled-fgoklch(0.68 0.008 220)oklch(0.56 0.012 225)
--lt-ring-width3px3px
--lt-ring-offset0px0px
--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-monoui-monospace, "SFMono-Regular", "Menlo", monospaceui-monospace, "SFMono-Regular", "Menlo", monospace
--lt-font-displayvar(--lt-font-sans)var(--lt-font-sans)
--lt-shadow-innerinset 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-1oklch(0.55 0.12 195)oklch(0.72 0.12 190)
--lt-chart-2oklch(0.58 0.14 250)oklch(0.7 0.12 250)
--lt-chart-3oklch(0.68 0.14 60)oklch(0.76 0.14 60)
--lt-chart-4oklch(0.58 0.19 305)oklch(0.7 0.16 305)
--lt-chart-5oklch(0.6 0.16 20)oklch(0.7 0.16 20)
--lt-chart-6oklch(0.62 0.13 150)oklch(0.74 0.12 150)
--lt-chart-7oklch(0.6 0.13 100)oklch(0.72 0.12 100)
--lt-chart-8oklch(0.55 0.05 280)oklch(0.68 0.05 280)
--lt-icon-xs0.75rem0.75rem
--lt-icon-sm0.875rem0.875rem
--lt-icon-md1rem1rem
--lt-icon-lg1.25rem1.25rem
--lt-icon-xl1.5rem1.5rem
--lt-icon-2xl2rem2rem
--lt-icon-3xl2.5rem2.5rem
--lt-icon-4xl3rem3rem
--lt-control-h-sm2rem2rem
--lt-control-h-md2.25rem2.25rem
--lt-control-h-lg2.5rem2.5rem
--lt-input-font-size-mobile1rem1rem
--lt-table-cell-xcalc(var(--spacing) * 4)calc(var(--spacing) * 4)
--lt-table-cell-ycalc(var(--spacing) * 4)calc(var(--spacing) * 4)
--lt-font-weight-normal400400
--lt-font-weight-medium500500
--lt-font-weight-semibold600600
--lt-font-weight-bold700700
--lt-shadow-xs0 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-sm0 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-md0 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-lg0 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-dropdown10001000
--lt-z-sticky11001100
--lt-z-overlay12001200
--lt-z-modal13001300
--lt-z-popover14001400
--lt-z-toast15001500
--lt-duration-fast120ms120ms
--lt-duration-base160ms160ms
--lt-duration-slow240ms240ms
--lt-sidebar-w16rem16rem
--lt-sidebar-w-collapsed4rem4rem
--lt-container-max80rem80rem
--lt-topbar-h3.5rem3.5rem
--lt-sticky-offset0px0px
--lt-guttercalc(var(--spacing) * 6)calc(var(--spacing) * 6)
--lt-table-pinned-bgvar(--lt-bg)var(--lt-bg)
--lt-table-pinned-muted-bgcolor-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-bgcolor-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-bgcolor-mix(in oklab, var(--lt-muted) 50%, var(--lt-bg))color-mix(in oklab, var(--lt-muted) 50%, var(--lt-bg))

Radius

TokenLightDark
--lt-radius0.5rem0.5rem
--lt-radius-smcalc(var(--lt-radius) - 0.125rem)calc(var(--lt-radius) - 0.125rem)
--lt-radius-xscalc(var(--lt-radius) - 0.25rem)calc(var(--lt-radius) - 0.25rem)
--lt-radius-lgcalc(var(--lt-radius) + 0.25rem)calc(var(--lt-radius) + 0.25rem)
--lt-radius-xlcalc(var(--lt-radius) + 0.5rem)calc(var(--lt-radius) + 0.5rem)
--lt-radius-full9999px9999px

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.