Skip to content

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:

  1. Use a role for its job. Why: the same role gets a different hex in every brand and scheme, and only roles are checked.
  2. 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.
  3. 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.selected for the one chosen thing in a list, not for decoration. Why: people read it as “this one”.
  • surface.inverse flips against the page (a tooltip). Only text.inverse is checked on it.
surface.canvas
surface.default
surface.raised
surface.sunken
surface.selected
surface.inverse
  • 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.brand is for links and a word of emphasis. It isn’t checked on surface.selected, so don’t use it in a selected row.
  • text.disabled is 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.inverse
  • text.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.

NewFeaturedRecommended for you
  • 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.subtle is decorative: card edges, dividers, chart grids. It needs no contrast, so never let it be the only thing that shows where something is.
  • border.default edges floating layers and secondary controls.
  • border.strong marks boundaries people must find, such as input edges. It reaches 3:1 against surface.canvas and surface.default.
subtledefaultstrong
  • 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.

Focused control
  • 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.

PaidPaidPaid
Due soonDue soonDue soon
OverdueOverdueOverdue
ScheduledScheduledScheduled
  • 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.
Text on surfacesneeds 4.5:1 (WCAG 1.4.3)
Foreground rolesurface.canvassurface.defaultsurface.raisedsurface.sunkensurface.selectedsurface.inverse
text.defaultGuaranteed, lowest 15.37:1, Harbor lightGuaranteed, lowest 15.89:1, Care darkGuaranteed, lowest 14.70:1, House darkGuaranteed, lowest 14.34:1, Vela lightGuaranteed, lowest 12.47:1, Harbor darkNot guaranteed
text.subtleGuaranteed, lowest 6.40:1, Harbor lightGuaranteed, lowest 6.71:1, Care lightGuaranteed, lowest 6.71:1, Care lightGuaranteed, lowest 5.98:1, Harbor lightGuaranteed, lowest 5.83:1, Haat lightNot guaranteed
text.brandGuaranteed, lowest 5.24:1, Qamar lightGuaranteed, lowest 5.51:1, Qamar lightGuaranteed, lowest 5.51:1, Qamar lightGuaranteed, lowest 4.89:1, Qamar lightGuaranteed, lowest 4.81:1, Qamar lightNot guaranteed
text.inverseNot guaranteedNot guaranteedNot guaranteedNot guaranteedNot guaranteedGuaranteed, lowest 16.12:1, Vela light
accent.textNot guaranteedGuaranteed, lowest 5.14:1, Vela lightNot guaranteedNot guaranteedNot guaranteedNot guaranteed
feedback.success.fgGuaranteed, lowest 5.79:1, House lightGuaranteed, lowest 6.09:1, Vela lightGuaranteed, lowest 6.09:1, Vela lightGuaranteed, lowest 5.41:1, Harbor lightGuaranteed, lowest 5.29:1, Haat lightNot guaranteed
feedback.warning.fgGuaranteed, lowest 6.22:1, House lightGuaranteed, lowest 6.53:1, Vela lightGuaranteed, lowest 6.53:1, Vela lightGuaranteed, lowest 5.81:1, Harbor lightGuaranteed, lowest 5.68:1, Haat lightNot guaranteed
feedback.danger.fgGuaranteed, lowest 6.72:1, House lightGuaranteed, lowest 7.06:1, Vela lightGuaranteed, lowest 7.06:1, Vela lightGuaranteed, lowest 6.28:1, Harbor lightGuaranteed, lowest 6.14:1, Haat lightNot guaranteed
feedback.info.fgGuaranteed, lowest 6.15:1, House lightGuaranteed, lowest 6.46:1, Vela lightGuaranteed, lowest 6.46:1, Vela lightGuaranteed, lowest 5.75:1, Harbor lightGuaranteed, lowest 5.61:1, Haat lightNot 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.

Labels on fillsneeds 4.5:1 (WCAG 1.4.3)
  • Aa
    action.primary.bgwith action.primary.fgGuaranteed, lowest 4.94:1, Care light
  • Aa
    action.primary.hoverwith action.primary.fgGuaranteed, lowest 5.85:1, Care light
  • Aa
    action.primary.pressedwith action.primary.fgGuaranteed, lowest 6.94:1, Care light
  • Aa
    action.secondary.bgwith action.secondary.fgGuaranteed, lowest 7.63:1, Haat dark
  • Aa
    action.secondary.hoverwith action.secondary.fgGuaranteed, lowest 6.98:1, Haat dark
  • Aa
    action.secondary.pressedwith action.secondary.fgGuaranteed, lowest 6.24:1, Haat dark
  • Aa
    accent.bgwith accent.fgGuaranteed, lowest 5.49:1, Harbor light
  • Aa
    accent.subtlewith accent.textGuaranteed, lowest 4.58:1, Vela light
  • Aa
    feedback.success.bgwith feedback.success.fgGuaranteed, lowest 5.43:1, House light
  • Aa
    feedback.warning.bgwith feedback.warning.fgGuaranteed, lowest 5.72:1, House light
  • Aa
    feedback.danger.bgwith feedback.danger.fgGuaranteed, lowest 6.18:1, House light
  • Aa
    feedback.info.bgwith feedback.info.fgGuaranteed, lowest 5.69:1, House light
  • Aa
    feedback.success.solidwith feedback.success.onSolidGuaranteed, lowest 4.96:1, House light
  • Aa
    feedback.warning.solidwith feedback.warning.onSolidGuaranteed, lowest 7.74:1, Vela light
  • Aa
    feedback.danger.solidwith feedback.danger.onSolidGuaranteed, lowest 4.74:1, House light
  • Aa
    feedback.info.solidwith feedback.info.onSolidGuaranteed, lowest 4.85:1, House light
Focus rings and input borders on surfacesneeds 3:1 (WCAG 1.4.11)
Foreground rolesurface.canvassurface.defaultsurface.raisedsurface.sunkensurface.selectedsurface.inverse
focus.ringGuaranteed, lowest 4.64:1, Care lightGuaranteed, lowest 4.86:1, Care lightGuaranteed, lowest 4.86:1, Care lightNot guaranteedGuaranteed, lowest 4.27:1, Care lightNot guaranteed
border.strongGuaranteed, lowest 3.39:1, House lightGuaranteed, lowest 3.56:1, Vela lightNot guaranteedNot guaranteedNot guaranteedNot 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.

Looks fine isn’t the same as checked

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.brand for 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:
WhereBackgroundText and icon
Soft badge, alertfeedback.*.bgfeedback.*.fg
Solid badgefeedback.*.solidfeedback.*.onSolid
Inline status textsurface.default, sunken or selectedfeedback.*.fg
Status dotfeedback.*.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-1 to --syntara-chart-4 in 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

  • Rent
  • Groceries
  • Travel
  • Other

3 categories. Rent: highest Jul ($1,200), lowest Jul ($1,200). Groceries: highest Aug ($460), lowest Sep ($390). Travel: highest Aug ($520), lowest Sep ($140). Other: highest Sep ($300), lowest Aug ($210).

Spending by category, July to September
MonthRentGroceriesTravelOther
Jul$1,200$420$180$260
Aug$1,200$460$520$210
Sep$1,200$390$140$300

Glass, gradients and tints

  • On glass, use only text.default and text.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 as surface.selected for 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.default at 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.strong into border.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

Your statement is readyView statement
Do

text.brand on surface.sunken. A checked pair.

Payment failed
Don’t

Feedback text on a brand fill. Nobody checks that pair: it drops to 1.01:1 in Qamar dark.

Status

Invoice 1042Overdue
Do

An icon and a word. The colour only adds emphasis.

Invoice 1042
Don’t

A red dot alone. People who can’t tell red from green, and screen readers, get nothing.

Primary actions

Do

One primary action per view. The rest step down.

Don’t

Three primaries. Nothing is the main action any more.

Brand on surfaces

Savings

Round up every payment

Do

Neutral surface, brand in two small places: the rule and the button.

Round up every payment
Don’t

A brand-filled panel. The button disappears into it, and the page gets loud.

Labels on fills

Paid
Do

A flat fill with its own label: action.primary.fg, onSolid.

Confirm transfer
Don’t

A gradient or overlay behind the label. Some fills are tuned to exactly 4.5:1, so any tint can fail.

Field edges

Do

The field’s edge reaches 3:1 against the surface outside it (border.strong).

Emailname@example.com
Don’t

A border.subtle hairline. Pretty, but people with low vision can’t find the field.

Chart labels

  • Rent
  • Groceries
Do

The swatch carries the colour; the words stay in text colours.

Groceries 38%
Don’t

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:themes

The fuzz generates random brands in light and dark and fails if any listed pair drops below its minimum. The latest results are on Accessibility.