Theming

Everything visual is a CSS custom property prefixed --tr-. Override any of them on :root (or on any element, to theme just that subtree) and every component follows.

Change the brand colour

Colours come in roles as Material 3 does: a strong colour, its text colour, a tonal container and the text on that container. Override the four to retheme primary:

:root {
  --tr-primary: #7c3aed;
  --tr-on-primary: #ffffff;
  --tr-primary-container: #ede9fe;
  --tr-on-primary-container: #2e1065;
}

/* dark mode */
:root[data-theme='dark'] {
  --tr-primary: #c4b5fd;
  --tr-on-primary: #2e1065;
  --tr-primary-container: #5b21b6;
  --tr-on-primary-container: #ede9fe;
}

If you follow the operating-system preference rather than data-theme, also set the dark values inside @media (prefers-color-scheme: dark).

Secondary colour

A second accent sits beside primary for highlights, tags and calls to action. It has the same four roles, defined for light and dark, and every text pair meets 4.5:1:

:root {
  --tr-secondary: #006874;
  --tr-on-secondary: #ffffff;
  --tr-secondary-container: #9eeffd;
  --tr-on-secondary-container: #001f24;
}

Text colour roles and contrast

A brand colour is usually picked for fills: buttons, logos, focus rings. The same colour used as small text on a surface can fall below the 4.5:1 ratio WCAG AA requires. Two tokens separate the two jobs. --tr-primary-text and --tr-secondary-text fall back to --tr-primary and --tr-secondary, so nothing changes until you set them, and they colour only text: Link, Eyebrow, outlined Tag, outlined and minimal Button, the primary Text tone, the current-page NavLink, the checked SegmentedControl option, the hovered Breadcrumbs link, matched text in option lists (Select, Combobox, MultiSelect), the DataTable active-filter icon, selected Tab and Select options, and similar. Fills, borders and focus rings stay on the fill token.

They are unset by default, so text follows the fill colour everywhere, including on a subtree where you re-theme --tr-primary. Once you set a text token, set it per mode, in the same blocks as your fill colours (a value that works on light surfaces is usually too dark for dark mode).

Required pairings

Each text token must reach 4.5:1 on every one of these surface roles, in light and in dark:

  • --tr-surface
  • --tr-surface-container-low
  • --tr-surface-container
  • --tr-surface-container-high
  • --tr-surface-container-highest

A failing palette and its fix

A brand orange #BF3F08 works as a button fill but measures 4.27:1 as text on a #E9E6DA container surface, which fails AA. Keep the fill and darken only the text role, which gives 5.27:1 on the same surface:

:root {
  --tr-primary: #BF3F08;        /* fills stay on brand */
  --tr-primary-text: #A63806;   /* 5.27:1 on #E9E6DA */
}

Check it at development time

The repository ships a script that computes these ratios for light and dark, prints a table and exits non-zero on any failure. Run it against the built-in palette, or pass your own stylesheet containing the overrides:

npm run check:contrast
node scripts/check-contrast.mjs src/theme.css

Put :root overrides in the light scope and :root[data-theme='dark'] (or a prefers-color-scheme: dark block) in the dark scope. The script resolves var() references, so overriding only --tr-primary is checked through --tr-primary-text. Colours must be hex.

Display font

Navbar, Badge, Tag and headings use --tr-font-display, which defaults to the body font so nothing changes until you set it. Pair it with tracking and case for a distinct brand voice without touching body text:

:root {
  --tr-font-display: 'Space Grotesk', var(--tr-font-sans);
  --tr-display-tracking: 0.04em;
  --tr-display-transform: uppercase;
}

Type and spacing scales

The dense UI sizes (--tr-text-xs to -xl) are fixed. For page headings there are three fluid sizes, --tr-text-2xl, -3xl and -4xl, built on clamp(), with matching --tr-leading-tight, -snug and -normal. Layout uses a 4px-based spacing scale, --tr-space-1 to --tr-space-12 (4px to 96px), plus --tr-container-max and --tr-gutter for page width and side padding.

Tokens only, your own fonts

The package splits its stylesheet in three so you can take only what you need. styles.css is all of them.

ImportContains
@ruckc/truss-ui/tokens.cssThe --tr-* design tokens only.
@ruckc/truss-ui/base.cssElement defaults (body font, colour and background, box-sizing), field primitives, the visually-hidden helper and reduced-motion handling. No fonts.
@ruckc/truss-ui/fonts.cssThe bundled Inter and JetBrains Mono variable fonts.
@ruckc/truss-ui/styles.cssFonts, tokens and base together.

To use your own typeface, import tokens and base, then point --tr-font-sans at it. No Truss font files are emitted:

import '@ruckc/truss-ui/tokens.css';
import '@ruckc/truss-ui/base.css';

/* your CSS */
:root { --tr-font-sans: 'Your Font', system-ui, sans-serif; }

Density and shape

:root {
  --tr-control-h: 28px;      /* default control height (32px) */
  --tr-radius-sm: 2px;       /* square it up */
  --tr-radius-md: 4px;
}

Inside an InputGroup, the size prop sets --tr-control-h locally, which is how every field in the group stays the same height.

Density

Set data-density to compact, default or comfortable on <html> (or pass density to Shell, which sets it for you). Control heights, row heights, gaps and the body type scale all follow. Leaving it unset is the same as default.

TokenCompactDefaultComfortable
--tr-control-h-sm20px24px28px
--tr-control-h26px32px40px
--tr-control-h-lg32px40px48px
--tr-row-h24px32px44px
--tr-row-h-head24px28px36px
--tr-tree-row-h22px28px36px
--tr-gap6px8px12px
--tr-text-md13px14px15px

--tr-row-h is the table body row, --tr-row-h-head the table header row and --tr-tree-row-h a tree row; use them in your own lists so they follow density too. The attribute works on any element, so one region can differ from the rest of the page, and regions nest:

<html data-density="compact">
  ...
  <section data-density="comfortable">a roomier region</section>
</html>

A local override still wins: an InputGroup sets --tr-control-h from its own size, whatever the page density is.

Chart palette

Charts use eight categorical series colours, --trc-s1 … --trc-s8, in a fixed order that is never cycled. The default set is validated for colour-blind separation in both modes. Colour follows the series, not its rank, so hiding a series never repaints the others.

Token reference

Typography

TokenLightDark
--tr-font-sans'Inter Variable', Inter, 'Segoe UI', system-ui, -apple-system, Roboto, sans-serifsame
--tr-font-mono'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospacesame
--tr-text-xs11pxsame
--tr-text-sm12pxsame
--tr-text-md14pxsame
--tr-text-lg16pxsame
--tr-text-xl20pxsame
--tr-text-2xlclamp(1.5rem, 1.2rem + 1.5vw, 2rem)same
--tr-text-3xlclamp(1.875rem, 1.4rem + 2.4vw, 2.75rem)same
--tr-text-4xlclamp(2.25rem, 1.5rem + 3.8vw, 3.75rem)same
--tr-leading-tight1.15same
--tr-leading-snug1.3same
--tr-leading-normal1.5same
--tr-font-displayvar(--tr-font-sans)same
--tr-display-trackingnormalsame
--tr-display-transformnonesame

Spacing & layout

TokenLightDark
--tr-space-14pxsame
--tr-space-28pxsame
--tr-space-312pxsame
--tr-space-416pxsame
--tr-space-520pxsame
--tr-space-624pxsame
--tr-space-732pxsame
--tr-space-840pxsame
--tr-space-948pxsame
--tr-space-1064pxsame
--tr-space-1180pxsame
--tr-space-1296pxsame
--tr-container-max72remsame
--tr-gutterclamp(16px, 4vw, 32px)same
--tr-navbar-h48pxsame

Sizing

TokenLightDark
--tr-control-h-sm24pxsame
--tr-control-h32pxsame
--tr-control-h-lg40pxsame
--tr-row-h32pxsame
--tr-row-h-head28pxsame
--tr-tree-row-h28pxsame
--tr-icon-sm16pxsame
--tr-icon-lg20pxsame
--tr-gap8pxsame

Shape

TokenLightDark
--tr-radius-sm4pxsame
--tr-radius-md8pxsame
--tr-radius-lg12pxsame
--tr-radius-pill999pxsame

Motion

TokenLightDark
--tr-easecubic-bezier(0.2, 0, 0, 1)same
--tr-dur-fast90mssame
--tr-dur160mssame

Intent colours

TokenLightDark
--tr-primary#1f5fbf#a8c7ff
--tr-on-primary#ffffff#002f68
--tr-primary-container#d8e4ff#0d448f
--tr-on-primary-container#001a41#d8e4ff
--tr-secondary#006874#82d3e0
--tr-on-secondary#ffffff#00363d
--tr-secondary-container#9eeffd#004f58
--tr-on-secondary-container#001f24#9eeffd
--tr-success#1b7a4b#7fd7a3
--tr-on-success#ffffff#00391d
--tr-success-container#cdeedb#0a5230
--tr-on-success-container#00210f#cdeedb
--tr-warning#8a5300#ffb958
--tr-on-warning#ffffff#482900
--tr-warning-container#ffe0a8#683d00
--tr-on-warning-container#2b1700#ffe0a8
--tr-danger#b3261e#ffb4ab
--tr-on-danger#ffffff#690005
--tr-danger-container#ffdad5#93000a
--tr-on-danger-container#410002#ffdad5

Surfaces & ink

TokenLightDark
--tr-surface#fbfbfe#111318
--tr-surface-container-low#f4f5fa#181a20
--tr-surface-container#eef0f6#1d2026
--tr-surface-container-high#e7e9f1#272a31
--tr-surface-container-highest#e0e3ec#32353c
--tr-on-surface#1a1c22#e2e2e9
--tr-on-surface-variant#454955#c3c6d1
--tr-placeholder#5f6471#9a9eac
--tr-outline#7a7f8e#8d919d
--tr-outline-variant#c3c7d3#434752
--tr-scrimrgb(10 14 24 / 0.4)rgb(0 0 0 / 0.55)

Elevation & focus

TokenLightDark
--tr-shadow-color24 30 480 0 0
--tr-elevation-10 1px 2px rgb(var(--tr-shadow-color) / 0.14), 0 1px 3px 1px rgb(var(--tr-shadow-color) / 0.08)same
--tr-elevation-20 2px 6px 2px rgb(var(--tr-shadow-color) / 0.12), 0 1px 2px rgb(var(--tr-shadow-color) / 0.2)same
--tr-elevation-30 6px 16px 4px rgb(var(--tr-shadow-color) / 0.14), 0 2px 4px rgb(var(--tr-shadow-color) / 0.2)same
--tr-elevation-40 12px 32px 8px rgb(var(--tr-shadow-color) / 0.18), 0 4px 8px rgb(var(--tr-shadow-color) / 0.24)same
--tr-focus-ring0 0 0 3px color-mix(in srgb, var(--tr-primary) 32%, transparent)same
/docs/theming Truss UI · MIT