Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Nuxt & VueLesson

CSS custom properties

A site's colours, fonts and spacing are used in hundreds of rules across dozens of components. Write each value into every rule and a change of palette means editing them all. Name each value once, as a CSS custom property on an element that wraps the pages, and every component inside reads it from there.

Know CSS custom properties already? Skip to In Terrarium.

Outside the box · General

The concept

A CSS custom property is a property whose name starts with two dashes, such as --accent: #356a4c. A rule reads it with var(--accent), or var(--accent, teal) to fall back to teal when it isn't set. Like color, a custom property set on an element is inherited by everything inside that element. Set on :root, it reaches the whole document; set on one class, it reaches only what that class wraps. A rule that sets it again on a narrower element, or inside a media query such as prefers-color-scheme: dark, overrides it there and below.

Vue's <style scoped> adds a data attribute to a component's elements and to its selectors, so the component's rules match only its own elements and the root element of each child component. That limits what a selector matches, not what an element inherits. A scoped component inside a themed wrapper reads the wrapper's custom properties with no extra code.

Vue uses custom properties for its own v-bind() in CSS too: a value bound to component state is compiled into a custom property, set on the component's root element.

Full referenceVue docs: SFC CSS Features

Looking inside

In Terrarium

Each brings its own look, and each keeps it in a stylesheet in its layer, layers/journal/app/assets/theme.css for the Journal. A layer's stylesheet loads on every page of every Tenant (a layer is not a sandbox). The Journal sets its tokens on one class, .jd, and its rules for the page itself read them:

layers/journal/app/assets/theme.css ↗8–64as of
8.jd {
⋯  /* … */
11  --jd-ground: #f3f5ef;
12  --jd-surface: #fbfcf9;
13  --jd-surface-2: #ecf0e6;
14  --jd-ink: #1a2420;
⋯  /* … */
57  background: var(--jd-ground);
58  color: var(--jd-ink);
59  font-family: var(--jd-serif);
⋯  /* … */
64}

Every Journal page in a Space wraps its template in that class, as <main class="jd jd-doc"> in its Document page. A component under it, such as the dashboard's stat tile, reads the tokens in its own scoped style:

layers/journal/app/components/journal/StatTile.vue ↗23–32as of
23<style scoped>
24.tile {
25  background: var(--jd-surface);
26  border: 1px solid var(--jd-line);
27  border-radius: var(--jd-radius);
28  padding: 1rem 1.1rem 1.05rem;
29  position: relative;
30  overflow: hidden;
31  box-shadow: var(--jd-shadow);
32}

Dark mode is the same tokens set again: a @media (prefers-color-scheme: dark) block redefines them on .jd, and no component changes. Tinkerfund gets the same result in one block, with CSS's light-dark() function in each token.

A wrapper class can also carry a narrower theme. Each Atlas is a biome, and its pages add a class named after the Space, which sets the room's paper colour and accents over the Atlas's base tokens:

layers/atlas/app/assets/theme.css ↗64–71as of
64.atlas--canopy {
65  --atlas-paper: #e6e6cf;
66  --atlas-paper-2: #eff0dd;
67  --biome-accent: #3f6b3e; /* leaf */
68  --biome-accent-2: #b8952f; /* dapple gold */
69  --biome-tint: rgb(63, 107, 62);
70  --biome-tint-amount: 5%;
71}

Not every Tenant puts its tokens on the wrapper. Some Tenants, such as the Blog and the Atlas, set them on :root and keep them apart by prefix, --bl- or --atlas-, while their rules still sit under a wrapper class such as .bl. Tinkerfund sets its tokens on a class it adds to <body> (layers/tinkerfund/app/components/tinkerfund/shell/Shell.vue), and on a second class, .tf-tokens, which the Platform's home page puts on Tinkerfund's tile (app/components/HomeShowcase.vue).

A Platform component can be themed the same way without knowing any Tenant. Mermaid diagrams read their colours from --diagram-* properties, listed in app/utils/mermaid.ts, and the Journal maps them to its own tokens with var().

Why it's built this way

  • Tokens set once, above the components. Colours written into each component's style mean editing every component for a new palette or dark mode. Terrarium defines each Tenant's tokens once, not per component, on an element above its pages (a wrapper class, <body> or :root), and scoped child styles inherit them. docs/agents/tenant-layers.md
  • Diagrams opt in by contract. A Platform component such as a Mermaid diagram can't know each Tenant's token names. It reads --diagram-* properties, which a Tenant maps to its own tokens, so diagram colours follow the Tenant and dark mode. The cost: a Tenant that maps none gets Mermaid's default colours. ADR-0024 (docs/adr/0024-build-time-mermaid-prerender.md)
  • One stylesheet for every page. A layer's stylesheet loads on every Tenant's pages (Nuxt layers says why), so Terrarium puts each Tenant's component rules under its own wrapper class, such as .jd or .bl. .claude/rules/tenant-layers.md

What to watch for

  • Outside the wrapper, no theme. A component that renders outside the wrapper element, for example through <Teleport> to <body>, doesn't inherit the tokens and falls back to browser defaults. A class on <body>, as Tinkerfund uses, covers teleported content too.
  • A wrong name fails silently. var() with a name nobody sets gives the fallback, or nothing, and the browser reports no error. A rule that reads var(--acent) when the token is --accent gets the inherited or default colour, not the accent.
  • Unprefixed tokens on :root collide. A token set on :root exists on every Tenant's pages. Give it the Tenant's prefix, or put it on the wrapper.
  • Map tokens with var(), not copies. A token defined as var(--jd-accent) follows the dark-mode override; one defined as the same colour literally keeps the light value.

Try it in your project

Pick one section of your app with its own look, such as an admin area. Move its colours into custom properties on a class, wrap the section's pages in it, and replace each colour in its components' scoped styles with var(--…):

.admin {
  --admin-surface: #ffffff;
  --admin-accent: #1f5fbf;
}
@media (prefers-color-scheme: dark) {
  .admin { --admin-surface: #13191d; --admin-accent: #8ab4ff; }
}

Switch your system to dark mode and check that the section follows with no component edited.

Where next

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/nuxt-vue/css-custom-properties