Skip to content
View as Markdown

Tokens

The first of LoamUI's three primitives: a handful of semantic custom properties that every component reads. A small palette of colours and neutrals, plus fluid scales: everything else is derived by recipe, so theming is overriding --loam-* values at any scope. No ThemeProvider, no JavaScript.

The surface

Small enough to read in full. A component library that mints thousands of tokens makes every one a decision nobody can audit; here the decisions are the hues and neutrals, and the rest is arithmetic:

Brand & status

The colour decisions a theme makes. Primary is neutral (black/white) by default, the white-label starting point; accent is the chromatic flourish; success/danger/warning/info are the status hues; link and highlight round out the platform defaults.

--loam-color-primary
--loam-color-accent
--loam-color-success
--loam-color-danger
--loam-color-warning
--loam-color-info
--loam-color-link
--loam-color-highlight

Neutrals

Surfaces, text and borders.

--loam-color-bg
--loam-color-bg-subtle
--loam-color-surface
--loam-color-fg
--loam-color-fg-strong
--loam-color-fg-muted
--loam-color-fg-dim
--loam-color-line
--loam-color-line-strong
--loam-color-on-strong

Derived

Recipes, not decisions: soft tints, solid fills and rings computed from the hues. Rebrand --loam-color-primary and these follow.

--loam-color-primary-soft
--loam-color-primary-strong
--loam-color-danger-soft
--loam-color-danger-strong
--loam-color-surface-hover
--loam-color-ring

Fonts

Two families a theme can swap (body and display), plus a monospace for code. System by default.

--loam-font
--loam-font-display
--loam-font-mono

Type scale

Fluid clamp() values in container units: they respond to the nearest container, or the viewport without one.

--loam-text-xs
--loam-text-sm
--loam-text-md
--loam-text-lg
--loam-text-xl
--loam-text-2xl
--loam-text-3xl

Spacing

The same fluid construction; minimums are the fixed values, so nothing shrinks below them.

--loam-space-xs
--loam-space-sm
--loam-space-md
--loam-space-lg
--loam-space-xl

Radius & motion

Deliberately fixed: rounding and durations don't breathe with the viewport.

--loam-radius-sm
--loam-radius-md
--loam-radius-lg
--loam-radius-xl
--loam-radius-full
--loam-duration-sm
--loam-duration-md
--loam-duration-lg
--loam-ease
--loam-ease-elastic

Elevation

Layered light-dark() shadows: a real drop in light; in dark the drop goes transparent and an inset top highlight carries the bevel, so the light source stays consistent.

--loam-shadow-sm
--loam-shadow-raised
--loam-shadow-md
--loam-shadow-lg

Values are read live from the loaded stylesheet with getComputedStyle, in your current colour scheme; fluid values show their computed size at this viewport.

Rebrand in one line

One token carries the brand: --loam-color-primary. Everything else (the soft tint, hover, active, solid fills, focus rings) is derived from it by recipe, so there is nothing else to keep in sync:

:root {  --loam-color-primary: light-dark(oklch(0.62 0.2 275), oklch(0.72 0.17 275)); /* violet */}

The brand colour appears wherever the design says primary: focus rings, checked states, carets, text selection, and primary context regions. (Buttons are neutral by default; the demo below is wrapped in a primary region so you can see the change.) Scope the token to a subtree to theme just part of a page: the nearest declaration up the tree wins, so both cards below run identical CSS and differ only in where the token is set:

<div style={{ "--loam-context": "primary" }}>  <Button>Default brand</Button>   {/* --loam-color-primary resolves at :root */}</div>
<div style={{  "--loam-color-primary": "light-dark(oklch(0.62 0.2 275), oklch(0.72 0.17 275))",  "--loam-context": "primary",}}>  <Button>Violet brand</Button>    {/* …resolves here instead */}</div>

Dark mode

Dark mode is native. Tokens are defined with CSS light-dark() and the root declares color-scheme: light dark, so the user's OS preference is followed with no JavaScript and no configuration; that is the default state.

The stylesheet can only speak once it has loaded. Add the matching meta tag so the browser paints the canvas in the right scheme before CSS arrives (otherwise dark-preference users get a flash of light canvas on every load):

<meta name="color-scheme" content="light dark" />

To override the preference, set data-theme="dark" or data-theme="light": on the root for the whole app, or on any element for just that subtree (the attribute simply sets color-scheme, so every light-dark() token re-resolves there). Remove the attribute to follow the OS preference again.

The same mechanism gives you an inverted "on-dark" section: set data-theme="dark" on the region (or color-scheme: dark in its CSS; the attribute is just a setter for it) and every light-dark() token flips. One caveat: colours already resolved on an ancestor inherit as resolved values and don't re-resolve, so the inverted region must also re-declare color (e.g. color: var(--loam-color-fg)) for descendants to pick up the flipped value.

This is one instance of LoamUI's baseline posture: the user's stated preferences are the default. Colour scheme is followed natively, motion exists only inside prefers-reduced-motion: no-preference, and forced colour palettes are honoured rather than overridden. Everything beyond that baseline (a saved theme, an animation) is an explicit opt-in layered on top.

Contexts

A context declares what a region means, as a custom property (--loam-context) that every LoamUI component inside adopts. The mechanics (the vocabulary, one-element regions, why the property lives on an ancestor) are the Contextualism guide's subject; what matters for theming is that a context remaps semantic colour tokens only, so it composes with everything on this page:

Delete workspace
/* the idiomatic form: a named region declares its meaning in   its own stylesheet (a style attribute works for one-offs) */.danger-zone {  --loam-context: danger;}
<section className="danger-zone">  {/* everything inside adopts the danger accent: buttons, checked      states, carets, text selection: even focus rings */}  <Checkbox label="I understand this is permanent" />  <Button>Delete</Button></section>

Theme, context, and instance are one mechanism at three scopes: remap tokens on :root to set a brand, declare a context on a region to give it meaning, set a property on an instance to override one control.

Most useful to override

Start here when theming:

  • --loam-color-primary: the brand colour (the soft tint, solid fill, focus ring and hover/active states all derive; there is nothing else to sync)
  • --loam-color-bg, --loam-color-surface, --loam-color-fg, --loam-color-line: surfaces & text
  • --loam-radius-md, --loam-radius-lg: corner rounding
  • --loam-font, --loam-font-display: the body and heading font families (both system-ui by default; set either and it flows through the whole stack)
  • --loam-duration-sm/md/lg, --loam-ease: motion by intent (micro feedback, defaults, overlay enter/exit)

Fluid type & spacing

The type (--loam-text-xs3xl) and spacing (--loam-space-xsxl) scales are fluid clamp() values in container units (cqi), generated with Utopia; the calculator parameters are committed as comments in tokens.css. Without a container they respond to the viewport; declare one on any region to make its LoamUI typography respond to that region's width instead:

.sidebar {  container-type: inline-size; /* LoamUI text in here now scales to the sidebar */}

Corner radii are deliberately not fluid: rounding shouldn't breathe. Control heights aren't tokens at all: buttons and form controls share one derived anatomy (padding + line-height + border), so they align by construction at every container width.

Because tokens cascade, you can theme per-brand or per-section by setting variables on any wrapper element; the whole theme is just values in the cascade.

Extending & overriding styles

Tokens cover most theming. When you need to change something a token doesn't expose, LoamUI gives you three escape hatches, and none of them need !important.

1. Target the class names

Every component's scope root has a stable, prefixed class: .loam-Button, .loam-Card, .loam-Input-field, and so on. The parts inside are plain elements and short classes, shown in each component's CSS tab. Because LoamUI's styles live inside a CSS @layer, any rule you write outside a layer automatically beats them; you never fight specificity:

/* Unlayered CSS always wins over LoamUI's layered CSS: no !important */.loam-Button {  text-transform: uppercase;  letter-spacing: 0.04em;}
.loam-Card {  box-shadow: 0 10px 40px -12px rgb(0 0 0 / 0.25);}

2. Add your own className

Every component forwards className and style (and all native DOM props) to its root element, so you can scope overrides to specific instances:

<Button className="cta">Subscribe</Button>
.cta {  border-radius: 999px;  padding-inline: 2rem;}

3. Order your own layer

Prefer explicit control over the cascade? Declare a layer after LoamUI's and put your overrides there; they win by layer order, no matter the selector specificity:

/* Declare the full order once: correct wherever it appears,   before or after importing LoamUI's stylesheet. */@layer loamui.tokens, loamui.elements, loamui.components, app;
@layer app {  .loam-Tabs .tab {    font-weight: 600;  }}

The class names are a stable, documented API; the exact selectors for every component are in its real stylesheet, under the CSS tab on its docs page.