Theming
Bloom runs on a hex token base, aliased to semantic tokens and bridged to Tailwind. One brand-hue decision cascades through every component, light and dark.
How color works
Bloom’s palette is hex, in three layers — each consuming the one below:
- Primitive base —
bloom-primitives.css: ~870 hex primitives under the--bloom-*prefix. These switch on[data-theme]and are the visual source of truth. - Semantic aliases —
theme.css: role tokens (--background,--primary,--card, the surface ladder, status families) point at the--bloom-*primitives. Because the primitives switch per theme, the aliases resolve per theme for free. - Tailwind bridge —
layout.css@theme inline: every--color-*maps to a semantic var, so you style with utilities (bg-primary,text-muted-foreground).
Neutral surfaces carry a faint brand-hue (purple) trace rather than sitting at pure grey, so the whole system reads as one family.
Current palette
These are the live tokens resolved from theme.css in the current theme. Flip dark mode in the top bar and they reroute.
Background
--background
Foreground
--foreground
Primary
--primary
Accent
--accent
Muted
--muted
Border
--border
Destructive
--destructive
Ring
--ring
For the complete inventory (destructive, sidebar, chart, form chrome) see Tokens → Colors.
How the token files are structured
The hex primitives live in bloom-primitives.css; the semantic aliases in theme.css. Each file is two blocks: :root (light) and [data-theme='dark']. No variable inherits across themes — dark redeclares everything it touches, so a rogue var() in light can’t leak.
/* bloom-primitives.css — hex base, switches on [data-theme] */
:root,
[data-theme='light'] {
--bloom-background-surface: #ffffff;
--bloom-background-surface-neutral: #faf9fa;
--bloom-background-button-brand-primary: #5f34ec;
/* …~870 --bloom-* primitives… */
}
[data-theme='dark'] {
--bloom-background-surface: #0c0c0e;
--bloom-background-button-brand-primary: #7e71f6;
/* …every primitive redeclared… */
}
/* theme.css — semantic aliases onto the primitives (theme-agnostic) */
:root,
[data-theme='light'] {
--radius: 0.875rem;
--page: var(--bloom-background-surface-neutral); /* canvas */
--background: var(--surface-1); /* feed / base */
--foreground: var(--bloom-text-base-primary);
--primary: var(--bloom-background-button-brand-primary);
--border: var(--bloom-border-base-neutral-subtle);
--ring: var(--bloom-border-action-focus);
}Overriding the brand hue
The only variable most teams need to change is --primary (and --ring, which mirrors it by convention). Override the semantic alias in theme.css with any valid CSS color — a hex or rgb() value, say — since this is the consumption layer. Everything else — surfaces, borders, destructive — stays canonical.
/* src/lib/tokens/theme.css — override just the brand hue */
:root {
--primary: #0d9488; /* teal — any valid CSS color works */
--ring: #0d9488;
}
[data-theme='dark'] {
--primary: #2dd4bf;
--ring: #2dd4bf;
}Keep both light and dark in sync. The dark brand sits lighter than the light one (Bloom ships #5f34ec light / #7e71f6 dark) so it reads against dark surfaces without hurting contrast against white foregrounds.
Contrast
Primary and destructive each have a -foreground pair. Always use the pair — don’t drop --foreground onto a --primary surface and hope. WCAG AA large text (4.5:1) is the floor; body copy on --muted aims for AA normal (7:1) in both themes.
Consuming tokens
In Tailwind v4, every variable mapped in the @theme inline block of layout.css becomes a utility: bg-primary, text-muted-foreground, border-border. Prefer utilities over raw var() references — it keeps the type surface predictable and lets lint catch typos. Never paste a raw hex into a component — always reach the palette through the semantic var or its Tailwind utility.
Substrate and field recipes
Two recipes sit on top of the semantic palette. The substrate chain is a data-substrate-aware press-state ladder for interactive surfaces; the field recipe is the shared idle/hover fill for form-adjacent primitives.
Substrate is relative. A primitive that writes hover:bg-substrate-hover does not resolve to a single absolute color — it resolves against the nearest data-substrate="N" ancestor. Card declares data-substrate="3", Dialog / Sheet / Drawer declare "5", Popover / DropdownMenu / Menubar / NavigationMenu / HoverCard / Select content declare "7". A Popover (substrate=7) inside a Dialog (substrate=5) inside a Card (substrate=3) composes correctly because each container redeclares its own --substrate-hover / --substrate-active for its descendants — there is no per-component branching. The hover/active overlays are translucent (they darken in light, lighten in dark), so the same two values are correct at every level. See /tokens/colors#substrate-context for the visual.
Field recipe. bg-input/30 border border-border hover:bg-substrate-hover is the shared idle/hover pattern across Badge variant="outline", every field primitive (Input, Textarea, Select, NumberField, InputGroup, CommandInput, InputOTPSlot, CurrencyPicker trigger), and non-Custom chips in LinkChips. Button variant="outline" shares the border + hover halves but stays bg-transparent at idle. The pattern is intentional duplication, not a utility — there is no cn-field shortcut. See /tokens/colors#field-recipe for the live example.