Skip to content

Commit a50f77b

Browse files
committed
Themes post
1 parent f36784f commit a50f77b

File tree

2 files changed

+268
-0
lines changed

2 files changed

+268
-0
lines changed
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
---
2+
title: 'Themes All the Way Down'
3+
author: [gallayl]
4+
tags: ['shades', 'shades-common-components', 'shades-showcase-app']
5+
date: '2026-03-24T12:00:00.000Z'
6+
draft: false
7+
image: img/016-shades-theme-system.jpg
8+
excerpt: "FuryStack Shades ships 19 themes, 90+ CSS variables, scoped nesting, and a ThemeProviderService that turns your entire design system into a runtime dial — here's how it actually works under the hood."
9+
---
10+
11+
Last time we [talked about the Showcase App](/posts/014-showcase-updates/), we casually name-dropped **19 themes** and moved on like that's a normal thing. Time to actually explain what's going on under the hood — because the theme system in Shades is not a color picker bolted on as an afterthought. It's a full design token architecture that controls colors, typography, spacing, shadows, border radii, transitions, blur effects, z-indices, and probably your emotional state if you spend enough time with the Sith palette.
12+
13+
## The Theme interface: more than meets the eye
14+
15+
Most UI libraries give you a "theme" that's basically `{ primary: '#3f51b5', secondary: '#1de9b6' }` and call it a day. Cute. Shades decided that was insufficiently over-engineered and went _much_ further.
16+
17+
The `Theme` interface defines the complete design language for an application:
18+
19+
```typescript
20+
export interface Theme {
21+
name: string;
22+
palette: Palette; // 6 semantic colors × 3 shades × 2 (color + contrast) = 36 color tokens
23+
text: Text; // primary, secondary, disabled
24+
button: ButtonColor; // active, hover, selected, disabled, disabledBackground
25+
background: Background; // default, paper, paperImage (yes, paper can have a background image)
26+
divider: Color;
27+
action: ActionColors; // hover, selected, active backgrounds + focus ring + backdrop + subtle border
28+
shape: Shape; // border radius scale (xs → full) + border width
29+
shadows: Shadows; // none → sm → md → lg → xl
30+
typography: ThemeTypography; // font family, 8 font sizes, 4 weights, 3 line heights, 6 letter spacings, text shadow
31+
transitions: Transitions; // 3 durations + 3 easings
32+
spacing: Spacing; // xs through xl
33+
zIndex: ZIndex; // drawer, appBar, modal, tooltip, dropdown
34+
effects: Effects; // 4 blur levels
35+
}
36+
```
37+
38+
That's roughly **90+ individually configurable design tokens** per theme. Change a theme, and _everything_ shifts — not just the background color, but how round buttons are, how text shadows render, which font loads, how fast transitions animate, and how thick borders appear. It's a full sensory overhaul.
39+
40+
The `Palette` alone would make a color theorist cry tears of well-contrasted joy:
41+
42+
```typescript
43+
export interface Palette {
44+
primary: ColorVariants; // light, main, dark + contrast pairs for each
45+
secondary: ColorVariants;
46+
error: ColorVariants;
47+
warning: ColorVariants;
48+
success: ColorVariants;
49+
info: ColorVariants;
50+
}
51+
```
52+
53+
Each semantic color comes in three shades (light, main, dark), and every shade carries a `contrast` color that's guaranteed to be readable on top of it. No more "is white or black text better on this particular shade of teal?" — the theme already answered that question for you.
54+
55+
## The CSS variable bridge
56+
57+
Here's where the architecture gets interesting. Components in Shades don't import theme colors directly. They reference CSS custom properties. Every. Single. Token.
58+
59+
The `cssVariableTheme` object is a `Theme` where every value is a `var(--shades-theme-*)` reference:
60+
61+
```typescript
62+
export const cssVariableTheme = {
63+
name: 'css-variable-theme',
64+
text: {
65+
primary: 'var(--shades-theme-text-primary)',
66+
secondary: 'var(--shades-theme-text-secondary)',
67+
disabled: 'var(--shades-theme-text-disabled)',
68+
},
69+
palette: {
70+
primary: {
71+
main: 'var(--shades-theme-palette-primary-main)',
72+
mainContrast: 'var(--shades-theme-palette-primary-main-contrast)',
73+
// ... you get the idea
74+
},
75+
// ... 5 more semantic colors
76+
},
77+
typography: {
78+
fontFamily: 'var(--shades-theme-typography-font-family)',
79+
fontSize: {
80+
xs: 'var(--shades-theme-typography-font-size-xs)',
81+
// ... 7 more sizes
82+
},
83+
// ... weights, line heights, letter spacing
84+
},
85+
// ... shadows, spacing, transitions, effects, zIndex, shape, action, button, background
86+
} satisfies Theme;
87+
```
88+
89+
When you build a component, you grab `cssVariableTheme` and use it in your styles:
90+
91+
```typescript
92+
const theme = cssVariableTheme;
93+
94+
const myStyles = {
95+
color: theme.text.primary,
96+
backgroundColor: theme.background.paper,
97+
borderRadius: theme.shape.borderRadius.md,
98+
fontFamily: theme.typography.fontFamily,
99+
transition: buildTransition([
100+
'background',
101+
theme.transitions.duration.normal,
102+
theme.transitions.easing.default,
103+
]),
104+
};
105+
```
106+
107+
Your component never knows or cares which concrete theme is active. It just points at CSS variables and trusts that _someone_ will fill them in. That someone is `useThemeCssVariables()`.
108+
109+
## How theme activation works
110+
111+
The `useThemeCssVariables()` function takes a theme object (the one with _actual_ hex values, font names, pixel sizes, etc.) and recursively walks the `cssVariableTheme` tree. For every leaf, it calls `root.style.setProperty()` to assign the concrete value to the corresponding CSS custom property:
112+
113+
```typescript
114+
export const useThemeCssVariables = (theme: DeepPartial<Theme>, root?: HTMLElement) => {
115+
root ??= document.querySelector(':root') as HTMLElement;
116+
assignValue(cssVariableTheme, theme, root);
117+
118+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
119+
setCssVariable(cssVariableTheme.transitions.duration.fast, '0s', root);
120+
setCssVariable(cssVariableTheme.transitions.duration.normal, '0s', root);
121+
setCssVariable(cssVariableTheme.transitions.duration.slow, '0s', root);
122+
}
123+
};
124+
```
125+
126+
Two things to notice here. First, the theme is `DeepPartial<Theme>` — you can provide _only_ the tokens you want to override, and the rest get removed (falling back to whatever the parent scope provides). Second, it automatically respects `prefers-reduced-motion` by zeroing out all transition durations. Accessibility baked in at the variable layer. No component-level checks needed.
127+
128+
The `ThemeProviderService` wraps this into an injectable singleton with event emission:
129+
130+
```typescript
131+
@Injectable({ lifetime: 'singleton' })
132+
export class ThemeProviderService extends EventHub<{ themeChanged: DeepPartial<Theme> }> {
133+
public readonly theme = cssVariableTheme;
134+
135+
public setAssignedTheme(theme: DeepPartial<Theme>, root?: HTMLElement) {
136+
this._assignedTheme = theme;
137+
useThemeCssVariables(theme, root);
138+
this.emit('themeChanged', theme);
139+
}
140+
}
141+
```
142+
143+
Call `setAssignedTheme()` with your theme object, and _every component in the tree_ instantly picks up the change — no re-render, no prop drilling, no context providers. The CSS variables update, the browser repaints, done. It's refreshingly boring in the best possible way.
144+
145+
## Nested themes: the real party trick
146+
147+
See that `root?: HTMLElement` parameter on `setAssignedTheme()`? That's where things get spicy.
148+
149+
By default, CSS variables are set on `:root` — the document element. Every component in the page sees them. But if you pass a specific DOM element as `root`, the variables are scoped to that subtree. Children of that element inherit the overridden values; everything outside remains untouched.
150+
151+
This means you can do things like this: render a Jedi theme on the left half of the screen and a Sith theme on the right, simultaneously, with zero interference. The Showcase App's Themes page does exactly this — multiple theme previews side by side, each living inside its own scoped `ThemeProviderService`. Light side, dark side, same page, no conflicts.
152+
153+
Because the scoping mechanism is just CSS custom property inheritance, nested themes compose naturally. A subtree can override only its palette and inherit everything else from the parent. Or it can replace the entire theme. Or it can do something truly cursed like running Neon Runner typography inside a Wild Hunt color scheme. The system won't judge you. TypeScript might, if you look at it hard enough.
154+
155+
## The 19 themes: a guided tour
156+
157+
### The sensible defaults
158+
159+
Every theme system needs a sane starting point. Shades ships two:
160+
161+
- **Default Dark**`#121212` background, Material Design–influenced indigo/teal palette, `system-ui` font stack. Clean, professional, zero surprises. The "I just want my app to look good without thinking about it" option.
162+
- **Default Light** — the inverse. Light backgrounds, adjusted contrast ratios, the same design language flipped. Still boring. Still exactly what you need.
163+
164+
### The pop culture collection
165+
166+
Then there are the other 17 themes, and they are... _thematic_.
167+
168+
Every franchise-inspired theme doesn't just swap colors — it reconfigures the entire design language. Font families change. Border radii shift. Shadow intensities adjust. Transition easings get personality. Here's a sampling to illustrate how far apart these themes actually sit:
169+
170+
**Neon Runner**_"Wake up, Samurai. We have a city to burn."_
171+
172+
Cyberpunk to the bone. `#0a0e17` background (basically the void, but darker). Electric cyan `#00f0ff` primary with hot magenta `#ff2d95` secondary. Monospace `Share Tech Mono` typography. Razor-sharp `3px` border radii. Shadows have a subtle neon glow (`rgba(0, 240, 255, 0.08)`). Even the letter spacing is wider — because in Night City, characters need their personal space.
173+
174+
**Wild Hunt**_"Wind's howling."_
175+
176+
Medieval fantasy in CSS form. `Cinzel` serif font — because nothing says "witcher medallion" like old-world typography. Border radius is `0px` across the board. Every corner is a hard edge, every surface is a weathered stone wall. The `paper` background even has an SVG-based `paperImage` for texture. The color palette is steel-silver and crimson, like a freshly polished silver sword right before things go badly for the nearest drowner.
177+
178+
**Plumber**_"It's-a me, Mario!"_
179+
180+
The _anti_-Wild Hunt. `Nunito` rounded sans-serif, `12px` border radii everywhere, bright `#f8f8ff` background. Nintendo red primary (`#e60012`), pipe-green success colors. The `easeOut` easing uses `cubic-bezier(0.34, 1.56, 0.64, 1)` — that's an overshoot bounce. Buttons literally _bounce_ when they animate. The font weights skew heavier (`medium: 600`, `bold: 800`) because in the Mushroom Kingdom, subtlety is not a power-up.
181+
182+
**Architect**_"There is no spoon."_
183+
184+
**Sandworm**_"The spice must flow."_
185+
186+
**Replicant**_"All those moments will be lost in time, like tears in rain."_
187+
188+
**Black Mesa**_"Rise and shine, Mr. Freeman. Rise and shine."_
189+
190+
...and the list goes on. Each one is a full `Theme` object with 90+ carefully chosen tokens. Not a CSS filter on top of the dark theme. Not "the same layout but blue." These are _designed_.
191+
192+
## The anatomy of a theme file
193+
194+
Every theme in the `themes/` directory follows a two-file pattern:
195+
196+
1. **`<name>-palette.ts`** — exports a `Palette` object with the 6 semantic colors and their variants
197+
2. **`<name>-theme.ts`** — imports the palette and builds the full `Theme` object around it
198+
199+
The palette is separated because it's the most reusable piece. You might want the Neon Runner color scheme but with your own typography and spacing. Import the palette, compose the rest yourself.
200+
201+
```typescript
202+
import { neonRunnerPalette } from './neon-runner-palette.js';
203+
import type { Theme } from '../services/theme-provider-service.js';
204+
205+
export const neonRunnerTheme = {
206+
name: 'neon-runner-theme',
207+
palette: neonRunnerPalette,
208+
typography: {
209+
fontFamily: "'Share Tech Mono', 'Fira Code', 'JetBrains Mono', 'Courier New', monospace",
210+
// ...
211+
},
212+
shape: {
213+
borderRadius: { xs: '2px', sm: '3px', md: '4px', lg: '6px', full: '50%' },
214+
borderWidth: '1px',
215+
},
216+
// ... rest of the theme
217+
} satisfies Theme;
218+
```
219+
220+
Note the `satisfies Theme` at the bottom. This is intentional — it validates the object against the `Theme` interface without widening the type. The theme retains its literal types, which means you get autocomplete on the exact values when you need to reference them directly. Type safety without type erasure.
221+
222+
## Monaco integration
223+
224+
The Showcase App doesn't stop at "the background color matches." The embedded Monaco editor reads the active theme's CSS variables and generates a matching editor color scheme on the fly. Switch to Neon Runner, and your code editor turns into a cyberpunk terminal with cyan syntax highlighting. Switch to Wild Hunt, and suddenly your function signatures look like inscriptions on an ancient scroll.
225+
226+
This works because Monaco supports programmatic theme definitions. The showcase maps the Shades palette tokens to Monaco's color entries: `editor.background` gets `background.paper`, `editor.foreground` gets `text.primary`, token colors derive from the palette's primary and secondary variants. One theme object, two rendering engines, zero manual sync.
227+
228+
## Lazy loading: because 19 themes at startup is a war crime
229+
230+
Every theme module is lazy-loaded. The Showcase App imports them dynamically:
231+
232+
```typescript
233+
const loadTheme = (name: string) => import(`./themes/${name}-theme.js`);
234+
```
235+
236+
Switch to Plumber, and only then does the Mario-red palette land in the browser. The initial bundle carries only the default theme and the `cssVariableTheme` indirection layer. The other 18 themes sit quietly on the server until someone actually asks for them. Your Lighthouse score is safe.
237+
238+
## Building your own theme
239+
240+
Creating a custom theme is straightforward:
241+
242+
1. Define your `Palette` — 6 colors, 3 shades each, with contrast pairs
243+
2. Build your `Theme` — fill in every section or spread from `defaultDarkTheme` and override what you want
244+
3. Use `satisfies Theme` for validation
245+
4. Call `themeProviderService.setAssignedTheme(yourTheme)`
246+
247+
Because the theme is `DeepPartial`, you can also do surgical overrides:
248+
249+
```typescript
250+
themeProviderService.setAssignedTheme({
251+
palette: { primary: { main: '#ff6600', mainContrast: '#000000' } },
252+
typography: { fontFamily: "'Comic Sans MS', cursive" },
253+
});
254+
```
255+
256+
That technically works. Please don't actually do it. Or do. The system won't stop you.
257+
258+
## What's next
259+
260+
The theme system is begging for a **theme builder** — a visual tool where you pick colors, tweak radii, adjust typography, and see the results live across every component in the showcase. The pieces are all there: the token structure is well-defined, the CSS variable bridge makes hot-reloading trivial, and the showcase already renders 60+ pages of components that would instantly reflect changes.
261+
262+
There's also room for **theme composition utilities** — functions that derive a dark variant from a light theme automatically, or generate accessible contrast colors from a single brand color. Right now each theme is hand-crafted (which is why they're so good), but not everyone wants to manually ensure WCAG AA compliance across 36 palette entries.
263+
264+
For now, though, 19 themes is a pretty solid foundation. Pick your franchise. Pick your side. And if the Neon Runner glow doesn't make you feel like a hacker, I don't know what will.
265+
266+
Want to see every theme in action? Head to the [Showcase App](https://shades-showcase.netlify.app/) and click through the theme dropdown. The source is in [`packages/shades-common-components/src/themes`](https://github.com/furystack/furystack/tree/develop/packages/shades-common-components/src/themes), and the `ThemeProviderService` lives in [`packages/shades-common-components/src/services`](https://github.com/furystack/furystack/tree/develop/packages/shades-common-components/src/services).
267+
268+
Go Sith. You know you want to.
1.7 MB
Loading

0 commit comments

Comments
 (0)