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
| Tier | Examples | Who reads it |
|---|---|---|
| Primitive | 12-step OKLCH ramps, the 4px space scale, radius, type scale, motion, elevation | The engine and Figma. Never components. |
| Semantic | color.surface.canvas, color.action.primary.bg, color.focus.ring | Components. |
| Component | control-height, table-row-height | Components, 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:
{
"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 variable | Vela | Harbor | Qamar | Care | Haat |
|---|---|---|---|---|---|
| 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.cssuses:root, so that tenant is the whole app’s theme. Its modes key off<html>, e.g.:root[data-syntara-scheme="dark"].dist/syntara.cssuses[data-syntara-theme="<id>"]for every tenant, so a tenant applies only inside an element orThemeScopethat 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
- Create
tenants/<id>/brand.jsonwith the six inputs, andcontent.jsonwith the tenant’s copy, locale and direction. - 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.