Skip to main content
Urbicon UI

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.

50
100
200
300
400
500
600
700
800
900
950

Cool blue-teal palette with deeper saturation. Chassis tuned cool to match.

Live Preview

Buttons

Badges

Primary Secondary Soft Soft

Form Elements

This stage is itself a scoped theme: the ramps and roles are re-declared inline on the element wrapping these components. Scoped Themes shows the pattern.

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:

A brand color alone is not a theme. Recolor --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;
}
Two things to get right.
  • 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 @theme wins. 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.

Check what your bundler does to light-dark(). Vite 8 minifies CSS with Lightning CSS by default, and against a CSS target below Safari 17.5 that rewrites 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

Not rendered here on purpose: it switches the whole site, and a second instance would fall out of step with the one in the sidebar.
<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