Skip to content

Theming

Token tiers, semantic roles, ThemeScope, and adding a tenant with one JSON file.

A Syntara theme is generated, not drawn. Six brand inputs go through the theme engine, which builds colour ramps, assigns semantic roles for light and dark, fixes every contrast failure it finds, and writes CSS variables. Components only ever read those variables.

Token tiers

TierExamplesWho reads it
Primitive12-step OKLCH ramps, the 4px space scale, radius, type scale, motion, elevationThe engine and Figma. Never components.
Semanticcolor.surface.canvas, color.action.primary.bg, color.focus.ringComponents.
Componentcontrol-height, table-row-heightComponents, only where a semantic role can’t express it.

A new brand changes values, never names — so no component knows which brand it renders (ADR-005).

A brand is six inputs

Primary colour, an optional accent, neutral temperature, shape, type pair and default density. This is Harbor’s whole brand:

tenants/harbor/brand.json
{
  "name": "Harbor",
  "primary": "#1D6B63",
  "accent": "#E07A3F",
  "neutral": "warm",
  "shape": "soft",
  "typePair": "calm",
  "density": "comfortable"
}

The engine keeps the brand hex exact where contrast allows and moves labels, rings and text first when it doesn’t. Every move is logged in a sentence, e.g. the house brand of this site:

  • dark · visibilityYour primary #18181b nearly disappears on the dark canvas (1.0:1), so dark-mode buttons use a lighter tone of the same hue, #4a4a4e (2.2:1).
  • dark · visibilityYour accent #18181b nearly disappears on the dark canvas (1.0:1), so dark-mode accent fills use a lighter tone of the same hue, #4a4a4e (2.2:1).
  • dark · contrast#18181b is too dark to see as a focus ring on the default surface #141415 (1.0:1), so the ring uses a lighter tone, #b7b7ba (7.2:1).

To generate a theme yourself:

import { generateTheme, toCSS } from '@syntara/theme-engine';

const theme = generateTheme({
  name: 'Acme',
  primary: '#0f766e',
  neutral: 'cool',
  shape: 'soft',
  typePair: 'technical',
  density: 'comfortable',
});

const css = toCSS(theme, { selector: '[data-syntara-theme="acme"]' });

Semantic roles

The 48 colour roles every theme resolves, with the CSS variable components read. Values are generated from each tenant’s brand when this page is built, and follow this page’s scheme — switch it in the header to see the dark values.

Role and CSS variableVelaHarborQamarCareHaat
Surface
color.surface.canvas--syntara-color-surface-canvas#f5f8fd#0c0d10#faf7f3#0f0d0b#faf7f3#0f0d0b#f7f8f9#0d0d0e#faf7f3#0f0d0b
color.surface.default--syntara-color-surface-default#fcfdff#121417#fffdfb#151311#fffdfb#151311#fdfdff#131415#fffdfb#151311
color.surface.raised--syntara-color-surface-raised#fcfdff#191c20#fffdfb#1e1b18#fffdfb#1e1b18#fdfdff#1b1c1d#fffdfb#1e1b18
color.surface.sunken--syntara-color-surface-sunken#ecf0f6#0c0d10#f3efeb#0f0d0b#f3efeb#0f0d0b#eff0f2#0d0d0e#f3efeb#0f0d0b
color.surface.selected--syntara-color-surface-selected#e9eeff#1d254c#dff4f0#1b2d2a#ffebd2#382406#e6efff#112750#ffe6f8#3e1837
color.surface.inverse--syntara-color-surface-inverse#1b2025#e9eff7#221f1a#f2eee7#221f1a#f2eee7#1e1f21#edeef1#221f1a#f2eee7
Text
color.text.default--syntara-color-text-default#1b2025#e9eff7#221f1a#f2eee7#221f1a#f2eee7#1e1f21#edeef1#221f1a#f2eee7
color.text.subtle--syntara-color-text-subtle#565b62#b2b8bf#5e5a55#bbb7b0#5e5a55#bbb7b0#595b5d#b6b7ba#5e5a55#bbb7b0
color.text.disabled--syntara-color-text-disabled#acb2b9#4e5359#b5b0aa#56524d#b5b0aa#56524d#b0b1b4#515255#b5b0aa#56524d
color.text.inverse--syntara-color-text-inverse#fcfdff#0c0d10#fffdfb#0f0d0b#fffdfb#0f0d0b#fdfdff#0d0d0e#fffdfb#0f0d0b
color.text.brand--syntara-color-text-brand#3f4bca#9fb3ff#286a62#8fc4bc#8e5e00#ffcd89#125be4#90b7ff#aa2595#f88ae1
Border
color.border.subtle--syntara-color-border-subtle#dbe0e6#202327#e3dfda#25221f#e3dfda#25221f#dee0e2#222324#e3dfda#25221f
color.border.default--syntara-color-border-default#c4c9d0#2f3338#cdc8c2#36322e#cdc8c2#36322e#c7c9cb#323335#cdc8c2#36322e
color.border.strong--syntara-color-border-strong#81878d#7b8187#8a8580#847f7a#8a8580#847f7a#858689#7f8083#8a8580#847f7a
Action
color.action.primary.bg--syntara-color-action-primary-bg#3d45d6#3d45d6#1d6b63#1d6b63#f2a516#f2a516#0e63ff#0e63ff#b5179e#b5179e
color.action.primary.fg--syntara-color-action-primary-fg#ffffff#ffffff#ffffff#ffffff#221f1a#0f0d0b#ffffff#ffffff#ffffff#ffffff
color.action.primary.hover--syntara-color-action-primary-hover#3437c8#3437c8#0a5f58#0a5f58#ffb233#ffb233#0057ed#0057ed#a60090#a60090
color.action.primary.pressed--syntara-color-action-primary-pressed#2d27ba#2d27ba#00544d#00544d#ffc572#ffc572#004dd5#004dd5#940081#940081
color.action.primary.border--syntara-color-action-primary-border#3d45d6#3d45d6#1d6b63#1d6b63#f2a516#f2a516#0e63ff#0e63ff#b5179e#b5179e
color.action.secondary.bg--syntara-color-action-secondary-bg#e9eeff#181e3a#dff4f0#162322#ffebd2#2c1d08#e6efff#101f3d#ffe6f8#31152b
color.action.secondary.fg--syntara-color-action-secondary-fg#1c216d#9fb3ff#0d3531#8fc4bc#402800#ffcd89#002571#90b7ff#520047#f88ae1
color.action.secondary.hover--syntara-color-action-secondary-hover#dce4ff#1d254c#cfece7#1b2d2a#ffdfb5#382406#d7e5ff#112750#ffd6f4#3e1837
color.action.secondary.pressed--syntara-color-action-secondary-pressed#ccd8ff#232c5d#bee2dc#1f3734#fad29b#442c03#c5daff#142f62#ffc3f0#4c1c43
Accent
color.accent.bg--syntara-color-accent-bg#12b5a6#12b5a6#e07a3f#e07a3f#7a2e8e#7a2e8e#f27f00#f27f00#f48c06#f48c06
color.accent.fg--syntara-color-accent-fg#1b2025#0c0d10#221f1a#0f0d0b#ffffff#ffffff#1e1f21#0d0d0e#221f1a#0f0d0b
color.accent.subtle--syntara-color-accent-subtle#d5f7f1#0d2f2b#ffeadf#3c2113#fae7ff#34203a#ffeadc#3e2008#ffeada#3c2207
color.accent.text--syntara-color-accent-text#007a6f#73d0c3#a34d13#f2a379#773489#d99ee9#9f5100#ffac70#995600#ffb87b
Focus
color.focus.ring--syntara-color-focus-ring#3d45d6#9fb3ff#1d6b63#8fc4bc#8e5e00#f2a516#0e63ff#90b7ff#b5179e#f88ae1
Feedback
color.feedback.success.bg--syntara-color-feedback-success-bg#dcf7e0#142517#dcf7e0#142517#dcf7e0#142517#dcf7e0#142517#dcf7e0#142517
color.feedback.success.fg--syntara-color-feedback-success-fg#0f7034#85cb93#0f7034#85cb93#0f7034#85cb93#0f7034#85cb93#0f7034#85cb93
color.feedback.success.border--syntara-color-feedback-success-border#8bcd98#255632#8bcd98#255632#8bcd98#255632#8bcd98#255632#8bcd98#255632
color.feedback.success.solid--syntara-color-feedback-success-solid#11813c#11813c#11813c#11813c#11813c#11813c#11813c#11813c#11813c#11813c
color.feedback.success.onSolid--syntara-color-feedback-success-on-solid#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff
color.feedback.warning.bg--syntara-color-feedback-warning-bg#ffebd1#2c1d08#ffebd1#2c1d08#ffebd1#2c1d08#ffebd1#2c1d08#ffebd1#2c1d08
color.feedback.warning.fg--syntara-color-feedback-warning-fg#7e5400#ffca80#7e5400#ffca80#7e5400#ffca80#7e5400#ffca80#7e5400#ffca80
color.feedback.warning.border--syntara-color-feedback-warning-border#e6b061#654200#e6b061#654200#e6b061#654200#e6b061#654200#e6b061#654200
color.feedback.warning.solid--syntara-color-feedback-warning-solid#efa30f#efa30f#efa30f#efa30f#efa30f#efa30f#efa30f#efa30f#efa30f#efa30f
color.feedback.warning.onSolid--syntara-color-feedback-warning-on-solid#1b2025#0c0d10#221f1a#0f0d0b#221f1a#0f0d0b#1e1f21#0d0d0e#221f1a#0f0d0b
color.feedback.danger.bg--syntara-color-feedback-danger-bg#ffe9e6#351613#ffe9e6#351613#ffe9e6#351613#ffe9e6#351613#ffe9e6#351613
color.feedback.danger.fg--syntara-color-feedback-danger-fg#ac1a1c#ff968a#ac1a1c#ff968a#ac1a1c#ff968a#ac1a1c#ff968a#ac1a1c#ff968a
color.feedback.danger.border--syntara-color-feedback-danger-border#ff9b90#7d2b26#ff9b90#7d2b26#ff9b90#7d2b26#ff9b90#7d2b26#ff9b90#7d2b26
color.feedback.danger.solid--syntara-color-feedback-danger-solid#d73431#d73431#d73431#d73431#d73431#d73431#d73431#d73431#d73431#d73431
color.feedback.danger.onSolid--syntara-color-feedback-danger-on-solid#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff
color.feedback.info.bg--syntara-color-feedback-info-bg#e3f0ff#122232#e3f0ff#122232#e3f0ff#122232#e3f0ff#122232#e3f0ff#122232
color.feedback.info.fg--syntara-color-feedback-info-fg#025fa6#7ebdfd#025fa6#7ebdfd#025fa6#7ebdfd#025fa6#7ebdfd#025fa6#7ebdfd
color.feedback.info.border--syntara-color-feedback-info-border#84c0fe#204d78#84c0fe#204d78#84c0fe#204d78#84c0fe#204d78#84c0fe#204d78
color.feedback.info.solid--syntara-color-feedback-info-solid#0f74c5#0f74c5#0f74c5#0f74c5#0f74c5#0f74c5#0f74c5#0f74c5#0f74c5#0f74c5
color.feedback.info.onSolid--syntara-color-feedback-info-on-solid#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff#ffffff

Which role to use for what, and which pairs are safe together: Using colour →

Other variables follow the same pattern: --syntara-space-{0…16}, --syntara-radius-{button,field,container,badge,pill}, --syntara-font-{heading,body,mono}, --syntara-font-size-{xs…5xl}, --syntara-font-tracking-{xs…5xl}, --syntara-shadow-{raised,overlay}, --syntara-motion-* and the density variables. The full contract is in packages/theme-engine/src/types.ts.

ThemeScope

Token CSS keys off data-syntara-theme, data-syntara-scheme and data-syntara-density, and they must sit on the same element. ThemeScope puts them there and gives the subtree the theme’s canvas, text colour and body font. Its locale prop also sets React Aria’s locale, lang and dir — see RTL.

import { ThemeScope } from '@syntara/react';

<ThemeScope theme="harbor" scheme="dark" density="compact">
  <ClaimForm />
</ThemeScope>

How token files are scoped

toCSS takes the selector for a theme’s base block and appends the mode attributes to it. @syntara/tokens builds two kinds of file:

  • dist/<id>/tokens.css uses :root, so that tenant is the whole app’s theme. Its modes key off <html>, e.g. :root[data-syntara-scheme="dark"].
  • dist/syntara.css uses [data-syntara-theme="<id>"] for every tenant, so a tenant applies only inside an element or ThemeScope that names it.

Several tenants on one page

Scopes nest and sit side by side, each with its own tenant, scheme and density. This site is the example: the chrome uses the house theme on :root, and every preview is a ThemeScope you can switch between tenants.

<div className="compare">
  <ThemeScope theme="vela"><PaymentCard /></ThemeScope>
  <ThemeScope theme="qamar" locale="ar-AE"><PaymentCard /></ThemeScope>
</div>

Dialogs, popovers and menus render in a portal at the end of <body>, outside any scope. When one opens, it copies the scope attributes, dir and lang from the element that opened it — so a dialog opened from a Harbor-dark preview is Harbor-dark (ADR-012). Try it: pick a tenant and a scheme, then open the dialog.

Adding a tenant

  1. Create tenants/<id>/brand.json with the six inputs, and content.json with the tenant’s copy, locale and direction.
  2. Run pnpm tokens. It fails if any contrast check fails, so a broken theme can’t ship.

That’s all. No component changes. pnpm tokens writes the new tenant’s files to @syntara/tokens, and this site picks it up on its next build, in the preview toolbar and the tables above.