Using colour
Which colour role to use for what, the pairs the engine guarantees, and how to check your own.
In Syntara you pick colour by role, never by hex. A role names a job, such as text.subtle or action.primary.bg. The theme engine resolves every role for each brand in light and dark. It guarantees contrast only for the pairs listed on this page. Everything else is up to you, and this page shows how to get it right.
Three rules cover most of it:
- Use a role for its job. Why: the same role gets a different hex in every brand and scheme, and only roles are checked.
- Put text only on backgrounds it’s checked against. Why: a pair that isn’t listed can pass in one brand and fail in the next.
- Never say something with colour alone. Why: many people can’t tell some colours apart, and screen readers don’t read colour.
For every hex in every brand, see Colors. For how themes are built, see Theming.
The roles by job
The swatches below are live. They read the CSS variables of the brand you pick, in the site’s current scheme (switch light and dark in the header). The hex beside each one is the engine’s output for that brand, generated when this page was built.
Surfaces
Surfaces stack. The page is surface.canvas. Content sits on surface.default, and cards sit on surface.raised. Don’t skip a step: depth reads from the order. surface.sunken goes the other way, an inset well inside a card or panel.
- Use
surface.selectedfor the one chosen thing in a list, not for decoration. Why: people read it as “this one”. surface.inverseflips against the page (a tooltip). Onlytext.inverseis checked on it.
surface.canvas#f7f7f9#0d0d0eThe page itself. Everything else sits on it.surface.default#fdfdff#141415Content areas, inputs, secondary buttons, table bodies.surface.raised#fdfdff#1c1c1dCards. One step up from default; in light mode the shadow carries the step.surface.sunken#f0f0f2#0d0d0eInset wells: code, a summary inside a card, neutral chips.surface.selected#eeeeef#29292aThe chosen row, the current nav item, a highlighted option.surface.inverse#1f1f21#eeeef1Tooltips and the one element that flips against the page. Only text.inverse sits on it.
Text
Most text is text.default or text.subtle. Both are checked on every surface except surface.inverse.
text.brandis for links and a word of emphasis. It isn’t checked onsurface.selected, so don’t use it in a selected row.text.disabledis only for disabled controls. WCAG exempts inactive controls from contrast, so the engine doesn’t check it and it sits well below 4.5:1. Never use it for text people need to read, like a hint or a placeholder.
Your refund of 2,400 is on its way
It usually arrives in 3 to 5 working days
Track the refund
text.inverse on surface.inversetext.default#1f1f21#eeeef1Body copy, headings, labels, values.text.subtle#5a5a5d#b7b7baDescriptions, captions, table headers, hints.text.disabled#b1b1b3#525255Labels of disabled controls only. Not checked for contrast.text.inverse#fdfdff#0d0d0eText on surface.inverse, and nowhere else.text.brand#18181b#b7b7baLinks and a word of emphasis. Not for paragraphs.
Action
action.primary.* is the brand’s solid button. action.secondary.* is its quiet, tinted sibling. The hover and pressed fills are checked with the same label, so states never lose contrast.
action.primary.bg#18181b#4a4a4eThe one main action in a view.action.primary.fg#ffffff#ffffffThe label and icon on a primary fill.action.primary.hover#0f0f12#3f3f43Primary fill under the pointer.action.primary.pressed#070709#353539Primary fill while pressed.action.primary.border#18181b#4a4a4eEdge of the primary fill where it needs one.action.secondary.bg#eeeeef#202021Quiet brand-tinted actions and the brand badge.action.secondary.fg#0b0b0d#b7b7baThe label on a secondary fill.action.secondary.hover#e4e4e6#29292aSecondary fill under the pointer.action.secondary.pressed#d9d9db#313133Secondary fill while pressed.
Accent
The accent is a second brand colour. Use it for highlights, not actions. accent.text is checked only on surface.default and accent.subtle.
accent.bg#18181b#4a4a4eA solid accent fill: a highlight chip, a marker.accent.fg#ffffff#ffffffThe label on accent.bg.accent.subtle#eeeeef#29292aA soft accent tint behind accent.text.accent.text#18181b#b7b7baAccent-coloured text on surface.default or accent.subtle.
Borders
border.subtleis decorative: card edges, dividers, chart grids. It needs no contrast, so never let it be the only thing that shows where something is.border.defaultedges floating layers and secondary controls.border.strongmarks boundaries people must find, such as input edges. It reaches 3:1 againstsurface.canvasandsurface.default.
border.subtle#dfdfe2#232324Decorative hairlines: card edges, dividers, chart grids. No contrast needed.border.default#c8c8cb#333335Visible edges on floating layers and secondary controls.border.strong#868688#808082Boundaries people must see: input edges. 3:1 against canvas and default.
Focus ring
focus.ring is the 2px keyboard focus outline, with a soft halo around it. It reaches 3:1 against canvas, default and raised surfaces. Never remove it or recolour it.
focus.ring#18181b#b7b7baThe 2px keyboard focus outline. 3:1 against canvas, default and raised.
Feedback
Each tone (success, warning, danger, info) has the same five roles. The jobs are the same for every tone, so they’re listed once.
- Swatches, left to right: success, warning, danger, info.
feedback.*.bgA soft tint for alerts and badges.feedback.*.fgStatus text: on its own bg, or on default, sunken or selected.feedback.*.borderThe hairline edge of an alert.feedback.*.solidA strong fill: solid badges, the dot of a status badge.feedback.*.onSolidThe label on solid.
Safe pairs
These tables are generated from contrast-pairs.json, the list the engine checks for every brand. If a pair isn’t here, it isn’t checked.
- ratio
- Guaranteed for every brand the engine generates. The number is the lowest ratio measured on this site: 6 brands in light and dark, 12 themes. Hover it to see where.
- Not guaranteed
- Not guaranteed. Don’t use it, even if it looks fine in your brand today.
| Foreground role | surface.canvas | surface.default | surface.raised | surface.sunken | surface.selected | surface.inverse |
|---|---|---|---|---|---|---|
text.default | Guaranteed, lowest 15.37:1, Harbor light | Guaranteed, lowest 15.89:1, Care dark | Guaranteed, lowest 14.70:1, House dark | Guaranteed, lowest 14.34:1, Vela light | Guaranteed, lowest 12.47:1, Harbor dark | Not guaranteed |
text.subtle | Guaranteed, lowest 6.40:1, Harbor light | Guaranteed, lowest 6.71:1, Care light | Guaranteed, lowest 6.71:1, Care light | Guaranteed, lowest 5.98:1, Harbor light | Guaranteed, lowest 5.83:1, Haat light | Not guaranteed |
text.brand | Guaranteed, lowest 5.24:1, Qamar light | Guaranteed, lowest 5.51:1, Qamar light | Guaranteed, lowest 5.51:1, Qamar light | Guaranteed, lowest 4.89:1, Qamar light | Guaranteed, lowest 4.81:1, Qamar light | Not guaranteed |
text.inverse | Not guaranteed | Not guaranteed | Not guaranteed | Not guaranteed | Not guaranteed | Guaranteed, lowest 16.12:1, Vela light |
accent.text | Not guaranteed | Guaranteed, lowest 5.14:1, Vela light | Not guaranteed | Not guaranteed | Not guaranteed | Not guaranteed |
feedback.success.fg | Guaranteed, lowest 5.79:1, House light | Guaranteed, lowest 6.09:1, Vela light | Guaranteed, lowest 6.09:1, Vela light | Guaranteed, lowest 5.41:1, Harbor light | Guaranteed, lowest 5.29:1, Haat light | Not guaranteed |
feedback.warning.fg | Guaranteed, lowest 6.22:1, House light | Guaranteed, lowest 6.53:1, Vela light | Guaranteed, lowest 6.53:1, Vela light | Guaranteed, lowest 5.81:1, Harbor light | Guaranteed, lowest 5.68:1, Haat light | Not guaranteed |
feedback.danger.fg | Guaranteed, lowest 6.72:1, House light | Guaranteed, lowest 7.06:1, Vela light | Guaranteed, lowest 7.06:1, Vela light | Guaranteed, lowest 6.28:1, Harbor light | Guaranteed, lowest 6.14:1, Haat light | Not guaranteed |
feedback.info.fg | Guaranteed, lowest 6.15:1, House light | Guaranteed, lowest 6.46:1, Vela light | Guaranteed, lowest 6.46:1, Vela light | Guaranteed, lowest 5.75:1, Harbor light | Guaranteed, lowest 5.61:1, Haat light | Not guaranteed |
text.default- surface.canvasGuaranteed, lowest 15.37:1, Harbor light
- surface.defaultGuaranteed, lowest 15.89:1, Care dark
- surface.raisedGuaranteed, lowest 14.70:1, House dark
- surface.sunkenGuaranteed, lowest 14.34:1, Vela light
- surface.selectedGuaranteed, lowest 12.47:1, Harbor dark
Not guaranteed on inverse.
text.subtle- surface.canvasGuaranteed, lowest 6.40:1, Harbor light
- surface.defaultGuaranteed, lowest 6.71:1, Care light
- surface.raisedGuaranteed, lowest 6.71:1, Care light
- surface.sunkenGuaranteed, lowest 5.98:1, Harbor light
- surface.selectedGuaranteed, lowest 5.83:1, Haat light
Not guaranteed on inverse.
text.brand- surface.canvasGuaranteed, lowest 5.24:1, Qamar light
- surface.defaultGuaranteed, lowest 5.51:1, Qamar light
- surface.raisedGuaranteed, lowest 5.51:1, Qamar light
- surface.sunkenGuaranteed, lowest 4.89:1, Qamar light
- surface.selectedGuaranteed, lowest 4.81:1, Qamar light
Not guaranteed on inverse.
text.inverse- surface.inverseGuaranteed, lowest 16.12:1, Vela light
Not guaranteed on canvas, default, raised, sunken, selected.
accent.text- surface.defaultGuaranteed, lowest 5.14:1, Vela light
Not guaranteed on canvas, raised, sunken, selected, inverse.
feedback.success.fg- surface.canvasGuaranteed, lowest 5.79:1, House light
- surface.defaultGuaranteed, lowest 6.09:1, Vela light
- surface.raisedGuaranteed, lowest 6.09:1, Vela light
- surface.sunkenGuaranteed, lowest 5.41:1, Harbor light
- surface.selectedGuaranteed, lowest 5.29:1, Haat light
Not guaranteed on inverse.
feedback.warning.fg- surface.canvasGuaranteed, lowest 6.22:1, House light
- surface.defaultGuaranteed, lowest 6.53:1, Vela light
- surface.raisedGuaranteed, lowest 6.53:1, Vela light
- surface.sunkenGuaranteed, lowest 5.81:1, Harbor light
- surface.selectedGuaranteed, lowest 5.68:1, Haat light
Not guaranteed on inverse.
feedback.danger.fg- surface.canvasGuaranteed, lowest 6.72:1, House light
- surface.defaultGuaranteed, lowest 7.06:1, Vela light
- surface.raisedGuaranteed, lowest 7.06:1, Vela light
- surface.sunkenGuaranteed, lowest 6.28:1, Harbor light
- surface.selectedGuaranteed, lowest 6.14:1, Haat light
Not guaranteed on inverse.
feedback.info.fg- surface.canvasGuaranteed, lowest 6.15:1, House light
- surface.defaultGuaranteed, lowest 6.46:1, Vela light
- surface.raisedGuaranteed, lowest 6.46:1, Vela light
- surface.sunkenGuaranteed, lowest 5.75:1, Harbor light
- surface.selectedGuaranteed, lowest 5.61:1, Haat light
Not guaranteed on inverse.
Labels on fills work differently: each label is checked only on its own fill.
- Aa
action.with action.primary. bg primary. fgGuaranteed, lowest 4.94:1, Care light - Aa
action.with action.primary. hover primary. fgGuaranteed, lowest 5.85:1, Care light - Aa
action.with action.primary. pressed primary. fgGuaranteed, lowest 6.94:1, Care light - Aa
action.with action.secondary. bg secondary. fgGuaranteed, lowest 7.63:1, Haat dark - Aa
action.with action.secondary. hover secondary. fgGuaranteed, lowest 6.98:1, Haat dark - Aa
action.with action.secondary. pressed secondary. fgGuaranteed, lowest 6.24:1, Haat dark - Aa
accent.with accent.bg fgGuaranteed, lowest 5.49:1, Harbor light - Aa
accent.with accent.subtle textGuaranteed, lowest 4.58:1, Vela light - Aa
feedback.with feedback.success. bg success. fgGuaranteed, lowest 5.43:1, House light - Aa
feedback.with feedback.warning. bg warning. fgGuaranteed, lowest 5.72:1, House light - Aa
feedback.with feedback.danger. bg danger. fgGuaranteed, lowest 6.18:1, House light - Aa
feedback.with feedback.info. bg info. fgGuaranteed, lowest 5.69:1, House light - Aa
feedback.with feedback.success. solid success. onSolidGuaranteed, lowest 4.96:1, House light - Aa
feedback.with feedback.warning. solid warning. onSolidGuaranteed, lowest 7.74:1, Vela light - Aa
feedback.with feedback.danger. solid danger. onSolidGuaranteed, lowest 4.74:1, House light - Aa
feedback.with feedback.info. solid info. onSolidGuaranteed, lowest 4.85:1, House light
| Foreground role | surface.canvas | surface.default | surface.raised | surface.sunken | surface.selected | surface.inverse |
|---|---|---|---|---|---|---|
focus.ring | Guaranteed, lowest 4.64:1, Care light | Guaranteed, lowest 4.86:1, Care light | Guaranteed, lowest 4.86:1, Care light | Not guaranteed | Guaranteed, lowest 4.27:1, Care light | Not guaranteed |
border.strong | Guaranteed, lowest 3.39:1, House light | Guaranteed, lowest 3.56:1, Vela light | Not guaranteed | Not guaranteed | Not guaranteed | Not guaranteed |
focus.ring- surface.canvasGuaranteed, lowest 4.64:1, Care light
- surface.defaultGuaranteed, lowest 4.86:1, Care light
- surface.raisedGuaranteed, lowest 4.86:1, Care light
- surface.selectedGuaranteed, lowest 4.27:1, Care light
Not guaranteed on sunken, inverse.
border.strong- surface.canvasGuaranteed, lowest 3.39:1, House light
- surface.defaultGuaranteed, lowest 3.56:1, Vela light
Not guaranteed on raised, sunken, selected, inverse.
Of the 23 unlisted pairs in these tables, 9 pass in all 12 themes on this site today. They still aren’t checked, so a new brand can break them.
Brand colour, sparingly
- One primary action per view. Why: the primary fill says “do this next”. Two of them cancel out.
- Use
text.brandfor links and emphasis, not paragraphs. Why: long runs of coloured text are harder to read and dilute the brand. - Keep surfaces neutral. Don’t flood a card or a page section with the brand fill. Why: brand works as a signal. A brand-filled panel also hides the primary button inside it.
- The glow is for dark mode and one hero element. The feature card and the current page in the sidebar are the only glowing things (ADR-013). Why: on a pale page a coloured halo reads as a smudge, and two glows compete.
Status colours
- Feedback colours are for state only: saved, due, failed, scheduled. Why: if green also means “brand”, success stops meaning anything.
- Always add an icon or a word. Never use colour alone. Why: people who can’t tell red from green, and screen readers, still get the message.
- Use the pair that matches the component:
| Where | Background | Text and icon |
|---|---|---|
| Soft badge, alert | feedback.*.bg | feedback.*.fg |
| Solid badge | feedback.*.solid | feedback.*.onSolid |
| Inline status text | surface.default, sunken or selected | feedback.*.fg |
| Status dot | feedback.*.solid (the dot) | The word beside it, in text.default |
Inline status text isn’t checked on surface.canvas or surface.raised. On a card, put it in a soft badge or a sunken well instead.
Charts
- Series use
--syntara-chart-1to--syntara-chart-4in that order. Never reorder them by rank or value. Why: a category keeps its colour across charts and filters, so people don’t have to re-learn the legend. - Series 1 follows the brand hue when the brand has one. The engine solves the others per brand, so neighbours stay apart for protan, deutan and tritan colour blindness (ADR-016).
- Text never goes in a series colour. Why: series promise 3:1, enough for bars and lines but not for text. The lowest on this site is 3.02:1 (Qamar light, series 1 on surface.raised).
- Grid lines use
--syntara-chart-grid(border.subtle, decorative). Axis labels use--syntara-chart-axis(text.subtle, checked for text).
Spending by category
Series keep their colour, whatever their size
Glass, gradients and tints
- On glass, use only
text.defaultandtext.subtle. The engine solves the glass opacity per brand (80–83% on this site) so those two reach 4.5:1 over any backdrop. Any other text colour goes on an opaque role, such assurface.selectedfor a highlighted row. - Never put a gradient or overlay behind a label on a solid fill. Why: the solver tunes some fills to exactly 4.5:1 with their label. Any tint can push them below it.
- Hover tints mix
text.defaultat 6% over the surface:color-mix(in oklab, var(--syntara-color-text-default) 6%, transparent). Why: it darkens in light mode and lightens in dark mode, in every brand, without a new colour. - The feature card’s gradient is capped and proven. It uses 6% of the brand in light and 30% in dark, fading to plain surface.raised by 70% of its radius. A test samples every pixel of it for text contrast in every tenant:
pnpm --filter @syntara/react exec vitest run test/card.test.tsx.
Fields and borders
Fields use a soft outline that still meets WCAG (ADR-017). The fill sits between surface.default and surface.sunken, so the edge sits between two close tones and looks quieter. The edge itself still reaches 3:1 against the surface outside the field.
- In light mode the edge is
border.strong. - In dark mode it mixes 80%
border.strongintoborder.default. Dark has more headroom, so it steps back a little. - On hover the edge gets stronger; on focus the 2px ring and halo take over.
Why not the 1.5:1 hairline many products use? It fails WCAG 1.4.11, and people with low vision can’t find the field.
Do and don’t
Each pair is live, in the brand you pick. The mistakes that would fail contrast are shown as pictures, so this page never ships a failing pair.
Brand text
text.brand on surface.sunken. A checked pair.
Feedback text on a brand fill. Nobody checks that pair: it drops to 1.01:1 in Qamar dark.
Status
An icon and a word. The colour only adds emphasis.
A red dot alone. People who can’t tell red from green, and screen readers, get nothing.
Primary actions
One primary action per view. The rest step down.
Three primaries. Nothing is the main action any more.
Brand on surfaces
Savings
Round up every payment
Neutral surface, brand in two small places: the rule and the button.
A brand-filled panel. The button disappears into it, and the page gets loud.
Labels on fills
A flat fill with its own label: action.primary.fg, onSolid.
A gradient or overlay behind the label. Some fills are tuned to exactly 4.5:1, so any tint can fail.
Field edges
The field’s edge reaches 3:1 against the surface outside it (border.strong).
A border.subtle hairline. Pretty, but people with low vision can’t find the field.
Chart labels
- Rent
- Groceries
The swatch carries the colour; the words stay in text colours.
Text in a series colour. Series only promise 3:1, and text needs 4.5:1.
Checking your own pairs
If you need a pair that isn’t listed, measure it for every brand you ship, in both schemes:
import { contrastRatio, generateTheme } from '@syntara/theme-engine';
const theme = generateTheme(brand);
for (const scheme of ['light', 'dark'] as const) {
const { roles } = theme.schemes[scheme];
const ratio = contrastRatio(roles['text.brand'].hex, roles['surface.selected'].hex);
console.log(scheme, ratio >= 4.5 ? 'passes' : 'fails', ratio);
}That covers the brands you have today, not the next one. If the pair should always work, ask for it to be added to packages/theme-engine/src/contrast-pairs.json. Then the solver fixes it for every brand, and the fuzz test covers it:
pnpm test:themesThe fuzz generates random brands in light and dark and fails if any listed pair drops below its minimum. The latest results are on Accessibility.