Design skins
Thirteen switchable visual designs — eleven token-only color skins plus two full "designs" (Bento Studio and Console) that also swap fonts, radius and the home dashboard — all built on the same design tokens, flash-free, and switchable live from four places.
Overview
Skins are a headline selling point of the template: the entire app can be re-colored (and, for two of them, re-typeset and re-laid-out) instantly, with the choice persisted and applied before first paint so there's never a flash of the wrong design.
Because design tokens are the only place colors live, a skin is nothing more than
a block of token overrides scoped under [data-skin="…"] on <html>. Switch the attribute and the whole UI re-skins.
Two categories:
- Color skins (token-only): Default, Aqua, Lime, Lilac, Prism, Midnight, Ocean, Graphite, Emerald, Amber, Coral. They redefine the raw color tokens (and nothing else) — variants of the standard layout.
- Full "designs": Bento Studio (
bento) and Console (console). In addition to colors they swap fonts and card radius, and render a bespoke home dashboard.
Every skin defines both a light and a dark variant, so skin and light/dark mode are orthogonal.
Architecture & files
| File | Responsibility |
|---|---|
src/styles/index.css | Defines the default palette, scoped to :root, [data-skin='default'] (+ .dark, [data-skin='default'].dark). Also the @theme inline mapping and skin-aware .font-display/.font-data helpers. |
src/styles/_skin-colors.scss | All eleven color skins except default — one [data-skin="x"] + one [data-skin="x"].dark block each (raw --* token overrides only). |
src/styles/_skin-bento.scss | Bento Studio: warm palette, big radius (--radius-card: 1.5rem), tile-tint tokens, flattened shadows. |
src/styles/_skin-console.scss | Console: terminal palette, monospace --font-*, tight radius (0.25rem). |
src/styles/_skin-prism.scss | Prism: neutral base + categorical --c1..--c6, applied to .stat-icon / .chart-bar marker classes. |
src/config/skins.ts | The Skin union and the SKINS array — the registry itself, kept free of React imports so build tooling can read it too. |
src/context/LayoutContext.tsx | skin/setSkin, <html data-skin> reflection, and localStorage('design_skin') persistence. Re-exports SKINS/Skin, so @/context/LayoutContext remains the import site. |
src/app/serverPrefs.ts | Validates the design_skin cookie against SKINS and renders data-skin server-side, so the first byte already carries the right design. |
src/layout/overlays/LayoutCustomizer.tsx | The "Design" skin picker; maps over SKINS, labels via customizer:skin<Cap>Label/Hint. |
src/layout/DesignSystemMenu.tsx | Header palette-icon quick switcher with live swatches. |
src/pages/HomeRoute.tsx | The home branch — renders BentoDashboard for bento, ConsoleDashboard for console, else CrmDashboard. |
src/locales/en/customizer.json | Per-skin skin<Cap>Label / skin<Cap>Hint strings. |
Mechanism at a glance: setSkin → React state → an effect writes <html data-skin> and
localStorage('design_skin') → the scoped SCSS block wins → the app re-skins. On reload,
the server reads the mirrored cookie and renders data-skin in the first byte.
Usage
For non-developers. A skin can be switched from four places, all sharing one source of truth:
- Layout Customizer (header gear → "Design" section) — cards with a label + hint.
- Layout Settings page (
/layout-settings) — the full-page twin of the Customizer. - ⌘K command palette — searchable skin entries.
- Header DesignSystemMenu (palette icon) — quick switch with live color swatches.
The choice is remembered across reloads and sessions.
For developers. Read and set the skin from the layout context:
import {useLayout, SKINS, type Skin} from '@/context/LayoutContext'
function SkinPicker() {
const {skin, setSkin} = useLayout()
return (
<div className="flex gap-2">
{SKINS.map((id) => (
<button
key={id}
onClick={() => setSkin(id)}
className={id === skin ? 'ring-2 ring-ring' : ''}
>
{id}
</button>
))}
</div>
)
}API / Props
The Skin type & SKINS list
From src/config/skins.ts (re-exported by @/context/LayoutContext, which stays the import site):
export type Skin =
| 'default' | 'bento' | 'aqua' | 'lime' | 'lilac' | 'prism'
| 'midnight' | 'ocean' | 'graphite' | 'emerald' | 'amber' | 'coral' | 'console'
export const SKINS: Skin[] = [
'default', 'aqua', 'lime', 'lilac', 'prism', 'midnight',
'ocean', 'graphite', 'emerald', 'amber', 'coral', 'console', 'bento',
]Info
SKINS defines the display order used by the Customizer, Layout Settings and ⌘K palette (it differs slightly from
the type's declaration order — bento is listed last). DEFAULT_SKIN is 'default'.
Skin catalog
data-skin | Label / hint (i18n) | Kind | Character | Notes |
|---|---|---|---|---|
default | Default · Indigo · clean | Color | Clean blue SaaS | Baseline palette in index.css. |
aqua | Aqua · Teal · cyan | Color | Teal / cyan | |
lime | Lime · Sky · lime | Color | Sky + vivid lime | Uses dark --primary-foreground (#0b0f0a) for contrast on lime. |
lilac | Lilac · Lavender | Color | Lavender / mauve | |
prism | Prism · Multicolor | Color | Violet chrome + multicolor data | Categorical --c1..--c6; see below. |
midnight | Midnight · Slate · indigo | Color | Slate-indigo | |
ocean | Ocean · Azure · blue | Color | Azure / blue | |
graphite | Graphite · Mono · minimal | Color | Mono near-black / light-gray | info stays blue for signal. |
emerald | Emerald · green | Color | Green | |
amber | Amber · gold | Color | Gold | Uses dark --primary-foreground (#1f1500) for contrast on amber. |
coral | Coral · warm | Color | Salmon / warm | |
console | Console · Dev · terminal | Design | Terminal green, JetBrains Mono, tight radius 0.25rem | Renders ConsoleDashboard. |
bento | Bento Studio · Warm · bento | Design | Warm tangerine, bento-grid, big radius 1.5rem | Keeps default font; renders BentoDashboard. |
Each color skin overrides the standard token set (--background, --surface, --surface-muted, --foreground,
--muted-foreground, --border, --primary, --primary-hover, --primary-foreground, --ring, and typically
--info) under [data-skin="x"] and again under [data-skin="x"].dark.
Layout context skin API
From useLayout():
| Member | Type | Description |
|---|---|---|
skin | Skin | The active skin. |
setSkin(s) | (Skin) => void | Switch skin; an effect reflects <html data-skin> and persists localStorage('design_skin'). |
Persistence key: localStorage('design_skin'). Invalid/unknown values fall back to 'default' (both at load time in
loadSkin() and in the pre-paint allowlist).
Prism categorical palette
Prism keeps chrome on a single violet --primary but colors data elements with a six-hue categorical palette (
src/styles/_skin-prism.scss):
| Var | Light | Dark |
|---|---|---|
--c1 | #7c3aed (violet) | #a78bfa |
--c2 | #ec4899 (pink) | #f472b6 |
--c3 | #f97316 (orange) | #fb923c |
--c4 | #14b8a6 (teal) | #2dd4bf |
--c5 | #3b82f6 (blue) | #60a5fa |
--c6 | #f59e0b (amber) | #fbbf24 |
Applied via marker classes: .stat-grid > * .stat-icon (each card a different hue, cycled 4n) and .chart-bar (
cycles the full palette 6n). These marker classes are harmless no-ops under every other skin.
Configuration & customization
Adding a skin
To ship a new color skin (say sunset), touch exactly these places:
src/styles/_skin-colors.scss— add a[data-skin="sunset"]block and a[data-skin="sunset"].darkblock, each redefining the 11 raw--*tokens (use raw--*, never--color-*).src/config/skins.ts— add'sunset'to theSkinunion and to theSKINSarray (its position sets the display order). That module is deliberately framework-free, so everything else — the customizer, Layout Settings, the ⌘K palette, the header menu — reads the list from one place.src/layout/overlays/LayoutCustomizer.tsx— the Customizer maps overSKINS, so it picks the new skin up automatically; just make sure its label keys exist (step 5). (Historically this was a separateskinIdslist; the current customizer iteratesSKINSdirectly.)- Nothing to register for the first paint. The server validates the
design_skincookie against the sameSKINSarray, so a skin added in step 2 is already trusted and rendered server-side. src/locales/en/customizer.json— addskinSunsetLabelandskinSunsetHintstrings (the Customizer/Settings/⌘K readcustomizer:skin<Cap>Label/Hint).
Layout Settings, the ⌘K palette and the header DesignSystemMenu then pick it up automatically from SKINS.
Adding a full "design"
A full design (like Bento or Console) does everything a color skin does plus:
- Its own
_skin-*.scsspartial with font/radius overrides (e.g.--font-sans/-display/-mono,--radius-card, extra tokens like Bento's tile tints). Bento also softens.shadow-smand maps.rounded-xl/.rounded-lgto its larger radius. - A branch in
HomeRoute()(src/pages/HomeRoute.tsx) to render its bespoke dashboard:
function HomeDashboard() {
const {skin} = useLayout()
if (skin === 'bento') return <BentoDashboard/>
if (skin === 'console') return <ConsoleDashboard/>
return <Dashboard/>
}Bento tiles live in src/components/bento/; the Console dashboard is src/pages/dashboards/ConsoleDashboard.tsx.
Examples
A skin's SCSS block (raw tokens, light + dark) — the shape you copy for a new color skin:
/* src/styles/_skin-colors.scss */
[data-skin='ocean'] {
--background: #f0f6fc;
--surface: #ffffff;
--surface-muted: #e6f0f9;
--foreground: #0c2030;
--muted-foreground: #51687a;
--border: #d7e6f1;
--primary: #0a84ff;
--primary-hover: #0a6fd6;
--primary-foreground: #ffffff;
--ring: #0a84ff;
--info: #22a7f0;
}
[data-skin='ocean'].dark {
--background: #04121f;
--surface: #0a1f30;
/* …the dark counterparts… */
--primary: #3aa0ff;
--primary-foreground: #04121f;
}A full-design partial adds fonts + radius (Console):
/* src/styles/_skin-console.scss */
[data-skin='console'] {
--primary: #15803d;
--font-sans: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--font-display: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--radius-card: 0.25rem;
}Rendering a per-skin swatch preview (scope a nested data-skin so it shows that skin's colors regardless of the active
one):
<div data-skin="emerald" className="bg-surface border border-border p-3 rounded-lg">
<span className="inline-block h-4 w-4 rounded-full bg-primary"/>
</div>Best practices
- Skin = token overrides only. Keep color skins purely to
--*values; don't add layout/behavior. Reserve font/radius/dashboard changes for genuine full "designs". - Always use raw
--*tokens in the SCSS, never--color-*(those are Tailwind aliases that resolve transparent in plain CSS). See design-tokens-and-dark-mode.md. - Define both light and dark for every skin — they're orthogonal to the theme mode.
- Watch
--primary-foregroundcontrast on light/vivid primaries. Lime and Amber deliberately use a dark--primary-foregroundso text on the accent stays legible. - Add the skin to
SKINSand nothing else needs trusting. The server validates the cookie against that array, so an id that is in the list survives a reload and one that isn't falls back todefault. - Register persisted keys.
design_skinis already handled by the "Reset to defaults" flow (src/lib/appStorage.ts); any new skin-related key must be added there too.
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
| New skin reverts to default after reload. | Its id isn't in the SKINS array, so the server rejects the cookie. Add it there. |
| New skin doesn't appear in the picker. | Missing from SKINS in LayoutContext, or its customizer:skin<Cap>Label key is absent. |
| Skin colors look transparent / broken in SCSS. | You used var(--color-*). Use raw var(--surface) etc. |
| Text on the accent is unreadable (light primary). | Set a dark --primary-foreground for that skin (as Lime/Amber do). |
| Prism multicolor isn't showing. | The data element is missing its marker class (.stat-icon inside .stat-grid, or .chart-bar). |
| Full design's dashboard doesn't change. | Add the skin === 'x' branch in HomeRoute() (src/pages/HomeRoute.tsx). |
| Nested "default" swatch inherits the active skin. | Ensure the default palette stays scoped to :root, [data-skin='default'] (it is, in index.css); wrap the swatch in data-skin="default". |
FAQ
How many skins ship? Thirteen: eleven color skins (including Default) plus two full designs (Bento Studio, Console).
Is a skin the same as dark mode? No. Skin (color identity) and mode (light/dark) are independent; every skin defines both light and dark. See design-tokens-and-dark-mode.md.
Do color skins change the layout? No — they're pure token overrides. Only the two full designs also swap fonts, radius and the home dashboard.
Why do Lime and Amber use dark text on the accent? Their --primary is a bright yellow/gold; white text would fail
contrast, so --primary-foreground is a near-black.
Where does the skin persist? localStorage('design_skin'), mirrored to a cookie of the same name so the
server can render <html data-skin> in the first byte — which is why switching skins survives a reload with
no flash of the default design.
Can OLED combine with a skin? Yes — OLED deepens the dark neutrals while the skin's --primary accent is preserved.
Notes for designers & content editors
- To design a new skin, you only pick eleven colors × two modes (light + dark). Match the token roles: canvas (
--background), cards (--surface), recessed (--surface-muted), text (--foreground/--muted-foreground), lines (--border), accent (--primary+ hover + foreground), focus (--ring), and--info. - Verify the accent contrast: whatever sits on a
--primaryfill uses--primary-foreground. If the accent is light, make that foreground dark. - Bento and Console are more than palettes — they change type and layout. Treat them as distinct product looks, not just color themes.
- Labels and one-line hints for each skin are editable in
src/locales/en/customizer.json(skin<Name>Label/skin<Name>Hint) and translated per language.
Related
Was this page helpful?
