Themes
Swap palettes with a single CSS import: each theme re-colors the primary and secondary accents and the neutral chassis. The four colored themes match the chassis to the accent’s temperature; Neutral strips it to grey.
Here for your own brand color? The Theme Builder generates the file; Write Your Own Theme explains it.
Cool blue-teal palette with deeper saturation. Chassis tuned cool to match.
Live Preview
Buttons
Badges
Form Elements
Usage
Theme import
/* app.css */
@import '@urbicon-ui/blocks/style/index.css';
@import '@urbicon-ui/blocks/style/themes/ocean.css';Write Your Own Theme
A theme is a CSS file with one @theme block that
re-tints three ramps: primary and secondary (the accents) plus --color-neutral-* (the chassis). Keep each stop's lightness and chroma; change only the hue. The full file is 37 ramp
stops long and the Theme Builder writes it for you; the template shows every decision in it:
--color-primary-* and nothing else, and your warm brand
button ends up sitting on cool blue-grey cards: surface-*, text-* and border-* derive from the neutral ramp, not from primary. A real theme also re-tints --color-neutral-* to the accent's temperature, and moves any
intent ramp your accent collides with (a green brand vs. success,
an amber one vs. warning).Custom theme file
/* my-theme.css */
@theme {
/* Primary: your brand. Keep each stop's lightness + chroma
profile (the WCAG-tuned contrast survives); change only the hue. */
--color-primary-50: oklch(0.95 0.03 280);
--color-primary-500: oklch(0.58 0.15 280);
--color-primary-600: oklch(0.52 0.15 280);
/* … all stops 50–950 … */
/* Secondary: the supporting accent, same rule. */
--color-secondary-500: oklch(0.55 0.12 320);
/* … all stops 50–950 … */
/* The chassis, NOT optional: surface-*, text-* and border-*
derive from neutral, so a purple brand on the default cool
240 chassis reads broken. Same lightness/chroma per stop,
only the hue moves (chroma 0 for a temperature-free grey).
Leave --color-neutral-0 (pure white) alone: tinting it
tints your white. */
--color-neutral-25: oklch(0.985 0.003 290);
--color-neutral-50: oklch(0.965 0.006 290);
/* … all 15 stops (25–950) … */
--color-neutral-950: oklch(0.08 0.008 290);
}
/* Raw partial values: :root, never @theme. Both are spliced into
a color function, so @theme would drop them on the floor. */
:root {
/* oklch L C H, no alpha. Shadows pick up the chassis temperature
instead of reading as cool smudges on tinted surfaces. */
--blocks-shadow-tint: 0.2 0.025 290;
/* Neutral intent chrome (bg-neutral / text-neutral / borders). */
--neutral-chrome-hue: 290;
}Import it where a shipped theme goes:
Import your own theme
/* app.css — your own file goes exactly where a shipped
theme would, after the library base styles. */
@import '@urbicon-ui/blocks/style/index.css';
@import './my-theme.css';When your accent lands within 20° of an intent hue (success 140, warning 80, danger 25, info
220), move that intent's ramp so a status color still reads as status rather than as your
brand. The Theme Builder flags the collision; which side moves is your call. forest.css ships the
worked example: its green primary pushes success from 140 to 172, its lime secondary pushes warning
from 80 to 60.
The same file in full, verbatim from the package. Beyond the two accent ramps it re-tints the
chassis, re-tunes success and warning, and sets the two :root values at the
end (shadow tint and neutral chrome hue).
forest.css, complete
/*
* @urbicon-ui/blocks – Forest Theme
*
* Earthy green palette inspired by natural environments.
* Import AFTER the base styles:
*
* @import '@urbicon-ui/blocks/style/index.css';
* @import '@urbicon-ui/blocks/style/themes/forest.css';
*/
@theme {
--color-primary-50: oklch(0.95 0.03 155);
--color-primary-100: oklch(0.9 0.05 155);
--color-primary-200: oklch(0.82 0.08 155);
--color-primary-300: oklch(0.74 0.1 155);
--color-primary-400: oklch(0.66 0.12 155);
--color-primary-500: oklch(0.58 0.13 155);
--color-primary-600: oklch(0.5 0.13 155);
--color-primary-700: oklch(0.42 0.11 155);
--color-primary-800: oklch(0.34 0.09 155);
--color-primary-900: oklch(0.26 0.06 155);
--color-primary-950: oklch(0.17 0.04 155);
--color-secondary-50: oklch(0.95 0.02 90);
--color-secondary-100: oklch(0.9 0.04 90);
--color-secondary-200: oklch(0.82 0.06 90);
--color-secondary-300: oklch(0.74 0.08 90);
--color-secondary-400: oklch(0.66 0.1 90);
--color-secondary-500: oklch(0.55 0.08 90);
--color-secondary-600: oklch(0.48 0.08 90);
--color-secondary-700: oklch(0.4 0.07 90);
--color-secondary-800: oklch(0.32 0.05 90);
--color-secondary-900: oklch(0.25 0.04 90);
--color-secondary-950: oklch(0.18 0.03 90);
/*
* Chassis neutral ramp — re-tinted toward a green-grey stone (Hue 150) so
* surfaces, text and borders read as natural/earthy rather than cool blue
* (the library default neutral sits at Hue 240). Lightness and chroma
* match the foundation ramp one-to-one — only the hue shifts — so WCAG
* contrast stays unchanged. surface-*, text-* and border-* pick this up
* automatically through semantic.css.
*/
--color-neutral-25: oklch(0.985 0.003 150);
--color-neutral-50: oklch(0.965 0.006 150);
--color-neutral-100: oklch(0.95 0.008 150);
--color-neutral-200: oklch(0.89 0.012 150);
--color-neutral-300: oklch(0.83 0.014 150);
--color-neutral-400: oklch(0.7 0.015 150);
--color-neutral-500: oklch(0.55 0.016 150);
--color-neutral-600: oklch(0.42 0.017 150);
--color-neutral-650: oklch(0.38 0.016 150);
--color-neutral-700: oklch(0.32 0.016 150);
--color-neutral-750: oklch(0.28 0.014 150);
--color-neutral-800: oklch(0.23 0.015 150);
--color-neutral-850: oklch(0.18 0.014 150);
--color-neutral-900: oklch(0.15 0.012 150);
--color-neutral-950: oklch(0.08 0.008 150);
/*
* Intent re-tuning — the green primary (Hue 155) collides with the default
* success (Hue 140), and the lime secondary (Hue 90) collides with the
* default warning (Hue 80). Success is pushed to a teal-green (Hue 172) so a
* "saved" state reads as verification rather than brand; warning is pulled
* to amber (Hue 60) so it leaves the lime register. Same L/C profile as the
* foundation ramps; only the hue shifts. Danger (25) and info (220) keep the
* library defaults. Derived --color-success / --color-warning re-resolve at
* :root automatically (global @theme).
*/
--color-success-50: oklch(0.95 0.03 172);
--color-success-100: oklch(0.9 0.05 172);
--color-success-200: oklch(0.82 0.08 172);
--color-success-300: oklch(0.74 0.11 172);
--color-success-400: oklch(0.62 0.14 172);
--color-success-500: oklch(0.5 0.15 172);
--color-success-600: oklch(0.44 0.14 172);
--color-success-700: oklch(0.38 0.12 172);
--color-success-800: oklch(0.3 0.1 172);
--color-success-900: oklch(0.22 0.08 172);
--color-success-950: oklch(0.14 0.05 172);
--color-warning-50: oklch(0.95 0.05 60);
--color-warning-100: oklch(0.9 0.08 60);
--color-warning-200: oklch(0.82 0.1 60);
--color-warning-300: oklch(0.74 0.12 60);
--color-warning-400: oklch(0.66 0.14 60);
--color-warning-500: oklch(0.75 0.15 60);
--color-warning-600: oklch(0.65 0.15 60);
/* 0.59 mirrors foundation.css: light-mode press fill under the dark
text-on-warning — hue 60 is the binding case (0.58 = 4.46:1, under AA). */
--color-warning-700: oklch(0.59 0.13 60);
--color-warning-800: oklch(0.45 0.11 60);
--color-warning-900: oklch(0.25 0.08 60);
--color-warning-950: oklch(0.15 0.05 60);
}
/*
* Earthy shadow tint — a faint deep green-grey so shadows sit naturally on the
* stone chassis. :root (not @theme) to mirror semantic.css; imported after the
* base styles, so it wins.
*/
:root {
--blocks-shadow-tint: 0.2 0.025 150;
/* Neutral intent (bg-neutral / text-neutral / borders) follows the stone
chassis instead of the library-default cool grey. */
--neutral-chrome-hue: 150;
}
Typography
Type is themed in the same @theme block:
sizes, weights, leading, tracking and families are Tailwind variables (--text-sm, --font-weight-medium, --font-sans, --font-mono).
Theme the type scale
/* app.css — the SAME @theme block that retunes color.
Safe because the library never re-imports Tailwind: your
@theme is compiled last and wins. */
@import 'tailwindcss';
@import '@urbicon-ui/blocks/style/index.css';
@theme {
/* Families — blocks never sets `font-sans`, so body type simply
inherits from your page. It DOES use `font-mono` (CommandPalette
shortcut keys, JourneyTimeline meta), so this retunes those. */
--font-sans: 'Inter Variable', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Size AND its paired line-height. Tailwind's built-in sizes each
ship a --text-*--line-height; changing the size alone leaves the
old rhythm behind on all ~163 text-sm call sites. */
--text-sm: 0.9375rem;
--text-sm--line-height: calc(1.375 / 0.9375);
--font-weight-medium: 550;
--leading-tight: 1.3;
--tracking-wide: 0.02em;
}- Change the paired line-height too. Tailwind's
built-in sizes each ship a companion
--text-*--line-height; resize without it and the rhythm goes subtly wrong everywhere the size is used. - One Tailwind compilation, yours. The library
deliberately does not
@import 'tailwindcss', so your@themewins. If your tooling introduces a second compilation, typography overrides silently revert, exactly like color overrides do. See docs/TailwindCaveats.md.
The leverage is lopsided: --text-sm reaches
the most call sites and nothing above text-2xl is used
by the library at all, so overriding --text-6xl changes nothing (the Token Reference lists per-size use counts). And because blocks never sets font-sans, body
type already inherits your page's font: you own that decision without any override.
Scoped Themes
Every token is a CSS custom property, so a theme can live on any selector, not just :root: a marketing
section with its own accent, an embedded product area, a per-tenant brand. Give the sub-tree a
class (<section class="promo">), then re-tint the ramp inside it. There is one trap: overriding the ramp stops is not
enough, because a var() inside a
token defined on :root substitutes at
that level: the derived roles keep their old values until you re-declare them inside the same scope.
A sub-tree with its own accent
/* One sub-tree, its own accent; the rest of the app keeps yours.
The chassis stays global: surfaces and text keep one temperature
across the page. */
.promo {
/* The re-tinted ramp, complete — the roles below read stops
from 50 all the way to 950. */
--color-primary-50: oklch(0.95 0.03 320);
--color-primary-100: oklch(0.9 0.05 320);
--color-primary-200: oklch(0.82 0.08 320);
--color-primary-300: oklch(0.74 0.11 320);
--color-primary-400: oklch(0.66 0.13 320);
--color-primary-500: oklch(0.58 0.15 320);
--color-primary-600: oklch(0.52 0.15 320);
--color-primary-700: oklch(0.44 0.13 320);
--color-primary-800: oklch(0.36 0.11 320);
--color-primary-900: oklch(0.28 0.08 320);
--color-primary-950: oklch(0.18 0.05 320);
/* Re-declare the derived roles. A var() inside a token defined
on :root substitutes THERE, at :root — overriding the ramp in
this scope changes nothing until the roles that read it are
re-declared in the same scope. */
--color-primary: light-dark(var(--color-primary-600), var(--color-primary-500));
--color-primary-hover: light-dark(var(--color-primary-700), var(--color-primary-400));
--color-primary-active: light-dark(var(--color-primary-800), var(--color-primary-300));
--color-primary-subtle: light-dark(var(--color-primary-50), var(--color-primary-900));
--color-primary-emphasis: light-dark(var(--color-primary-900), var(--color-primary-200));
}Secondary works the same way: re-tint --color-secondary-* and re-declare its five roles. Focus ring, selected surfaces and chart colors derive from primary
too (--color-interactive-*, --color-surface-selected, --color-chart-1);
re-declare them if your section uses them.
Two worked examples of the pattern: the live previews on this page and in the Theme Builder
(the inline style re-declares the roles next to the ramps, see apps/docs/src/lib/theme-preview.ts), and this docs site itself, which re-derives the primary family per page from the component
family it documents: Color Rooms.
light-dark(a, b) into two guard variables. Those are
substituted where the token is declared, not where it is read — so a scope carrying
its own color-scheme can no longer switch any token it does not
re-declare itself, and a dark section inside a light page renders the light branch with no
warning. Raise build.cssTarget to versions that ship light-dark() natively (chrome123, edge123, firefox120, safari17.5). The tell is lightningcss-light in your built CSS.Dark Mode
Your theme file has no dark variant, and does not need one: every semantic role reads one stop
per mode off the ramps you already re-tinted, through the CSS light-dark() function. :root declares color-scheme: light dark, the browser picks each token's branch from the user's preference, and a manual choice only
sets :root.light or :root.dark to override
it.
The ready-made toggle is the ThemeSwitcher component: it cycles light → dark → system, sets the class, and persists the choice to localStorage. Its
page also carries the app.html head snippet
that keeps the first paint flash-free.
The ready-made toggle
<ThemeSwitcher />Setting the class yourself:
Manual mode switch
const html = document.documentElement.classList;
html.remove('light', 'dark'); // follow the OS (color-scheme: light dark)
html.add('dark'); // force dark; add('light') forces light