Skip to content
AstroCraft Docs
On this theme

Colors & Theming

Urbic’s colours are CSS-first. There is no JavaScript theme object, no tailwind.config.js colour map to keep in sync — everything is custom properties across two files, in three layers.

The three layers

Layer 1 lives in src/styles/tailwind-theme.css under @theme: palette aliases that name a Tailwind ramp as yours.

--color-primary-50: var(--color-violet-50);
/* … through 950 */
--color-base-50: var(--color-stone-50);
/* … through 950 */

Urbic ships violet as the primary and stone as the neutral. Both are full eleven-step ramps.

Layer 2 lives in src/styles/global.css under @layer base: semantic runtime variables on :root, redefined on .dark.

:root {
  --background: var(--color-base-50);
  --foreground: var(--color-base-900);
  --primary: var(--color-primary-700);
  --muted: var(--color-base-100);
  /* … */
}

Layer 3 is back in tailwind-theme.css, under @theme inline, bridging those runtime variables to utilities so bg-background and text-primary resolve to the variable and flip with the theme. The inline keyword is required — without it they resolve to literal values and dark mode stops working.

Rebranding

Repoint the Layer 1 aliases. That is the whole edit:

@theme {
  --color-primary-50: var(--color-emerald-50);
  --color-primary-100: var(--color-emerald-100);
  /* … through 950 */
}

Markup only ever uses bg-primary, text-foreground, text-base-700 and their kin — never bg-violet-700 or text-stone-300 — so light and dark follow automatically. That rule is the fourth item in the primitive contract, and it is what keeps re-theming a one-file change across 45 primitives and every section.

If you want a different relationship between the palette and the UI rather than a different palette — a lighter primary, a warmer muted surface — edit Layer 2 instead. Both :root and .dark are complete, so a token changed in one needs its pair considered in the other.

The semantic set

Beyond background, foreground, card, primary, secondary, muted and accent, there are four status families — info, success, warning, error — each with a -foreground pair, and three of them with an extra -accent.

The -accent tokens are worth understanding before you delete them. --success and --warning are surfaces: they pair with a foreground and they are 300-level, which fails contrast as text or as a hairline on a neutral ground. The -accent form is the same status read as text or as a border. The CMS’s sign-in and review screens draw both forms, which is where the distinction came from.

The two that do not follow the palette

--input is base-500 in both themes, deliberately. It is control chrome, not decoration — the resting border on ui/_field, on Checkbox and Radio’s boxes, on AdvancedSelect’s search field, on Switch’s off-track — and every one of those sits on a transparent or near-ground fill, so it is the only edge there is. WCAG 1.4.11 asks 3:1 of the visual information identifying a UI component. Measured on this ramp: base-100 on base-50 is 1.04:1 and base-800 on base-950 is 1.30:1, two invisible boundaries. base-500 is the one value clearing 3:1 against both ends — 4.59:1 on base-50, 4.12:1 on base-950 — so it needs no per-theme pair.

A decorative hairline is not a control and still uses --border, which does follow the theme.

--error-accent is the other exception: red-700 in light, red-400 in dark. red-800 is a surface colour, and as a border on bg-background it is all but invisible — which is how an invalid field ends up looking valid.

Dark mode

Class-based, via @variant dark (&:where(.dark, .dark *)). BaseHead sets .dark on <html> pre-paint, so there is no flash. The theme-toggle primitive in src/components/ui/theme-toggle/ is the control; the header draws it.

Because every component reads semantic tokens, dark mode costs nothing per component. The way to check a new primitive is /examples/ui in both modes — a missing token shows up instantly as an un-themed element.

What else is in the token layer

--radius is 0.5rem, and the --radius-xs through --radius-xl scale in @theme inline derives from it, so one edit changes every corner on the site.

Two geometry tokens are site facts rather than component ones. --row-line: 30px is the site’s line grid — the Figma file pins the nav’s 32px serif, the footer’s 16px body and the about section’s 18px terms all to the same 30px. Pin it on the font size (text-base/(--row-line)), never as a separate leading-*: tailwind-merge treats font-size as conflicting with leading and silently drops the leading-*. And --bar-h: 74px is the header height, declared at :root because three pages need a band to run under the bar with -mt-(--bar-h) and cannot read a variable declared on <header>.

Breakpoints add one of their own: xs at 400px, with the rest mirroring Tailwind’s defaults so they are explicit. There is also a viewport-height variant, tall, at min-height: 800px — every Tailwind breakpoint is a width, and a full-viewport section competing for vertical space is exactly the case a lg: misses.

NEXT STEPTypography