Text, Colour & Backgrounds
Every value on this page is an ACSS 4 token. No chip used here contains a literal value. Every text and background pair rendered here clears WCAG AA.
This is the layer underneath the other reference pages. /acss-4-elements/ covers buttons and elements; /acss-4-cards/ covers cards, borders, dividers and shadows. This page covers what they are all built from: the type scale, the palette, the contextual colour tokens, and the four background bands.
The three rules this page follows
- Zero literals. A chip body contains no number, no hex, no rgba(), no oklch(). If a value cannot be expressed as an ACSS variable, the variable is the thing that is missing — not the chip.
- Contextual before explicit. A token that re-points itself per band beats a modifier class per band. One chip that works on white, light, dark and ultra-dark is worth four that each work on one.
- Nothing renders below AA. Where a token pair fails, this page reports the number rather than showing the failure as live text. Three such failures were found in this template; they are listed at the bottom with their fixes.
The text size scale
Six sizes, all fluid clamp() expressions generated by Typography → Text Sizes. Body copy is --text-m and needs no class at all; the five other sizes are utility classes. Measurements below were taken at a 1440px viewport.
The quick brown fox jumps over the lazy dog
.text--xxl · var(--text-xxl) · 41.4px
The quick brown fox jumps over the lazy dog
.text--xl · var(--text-xl) · 31.2px
The quick brown fox jumps over the lazy dog
.text--l · var(--text-l) · 23.6px
The quick brown fox jumps over the lazy dog
.text--m · var(--text-m) · 17.8px — the body default, no class needed
The quick brown fox jumps over the lazy dog
.text--s · var(--text-s) · 13.5px
The quick brown fox jumps over the lazy dog
.text--xs · var(--text-xs) · 10.3px
.text--xs renders 10.3px here. It passes contrast at every band, but 10px is small: .text--s is the safer floor for anything a visitor is expected to read. Nothing on this page uses .text--xs for content.
The heading scale
Rendered with the .h1 to .h6 size utilities on paragraphs, so the page keeps one h1 and a clean outline. Sizes measured at 1440px.
Heading 1
.h1 · var(--h1) · 59.2px
Heading 2
.h2 · var(--h2) · 47.5px
Heading 3
.h3 · var(--h3) · 38.1px
Heading 4
.h4 · var(--h4) · 30.6px
Heading 5
.h5 · var(--h5) · 23.8px
Heading 6
.h6 · var(--h6) · 19.2px
What .h1 to .h6 do not do
A real h3 element
A paragraph carrying .h3
The utilities set font-size and max-width only. Weight (var(--heading-font-weight), 700) and family come from the heading element itself, so .h3 on a paragraph gives you the size and not the voice. Use the utility when the size is what you want; use the element when the document structure is what you want.
Worth knowing: --h5 and --h6 clamp down to 0.8125rem and 0.75rem, which is 13px and 12px. Body text clamps down to 1rem, 16px. On a narrow viewport an h5 and an h6 are therefore smaller than the body copy underneath them. That is a scale decision in Typography → Headings, not a bug, but it is rarely what anyone intends.
The palette: four roles, eight shades
Thirty-two tokens, plus --white and --black. The two numbers under each swatch are its contrast against white and against black — read them as "which ground can this colour carry text on".
There are no colour utility classes for any of these. ACSS 4 exposes the palette as variables only. The complete list of colour classes in the framework is four: .text--dark, .text--dark-muted, .text--light and .text--light-muted. Everything else is var(--token) inside a chip, or a contextual token that the band re-points for you.
--primary
--primary
#33a2c1 · white 2.97 · black 7.08
--primary-ultra-light
#ebf7fa · white 1.09 · black 19.22
--primary-light
#c2e6ef · white 1.32 · black 15.86
--primary-semi-light
#71c4da · white 1.98 · black 10.61
--primary-semi-dark
#25778e · white 5.11 · black 4.11
--primary-dark
#1a5565 · white 8.30 · black 2.53
--primary-ultra-dark
#0a2229 · white 16.49 · black 1.27
--primary-hover
#0084a0 · white 4.37 · black 4.81
--base
--base
#0a2434 · white 15.97 · black 1.31
--base-ultra-light
#eaf4fb · white 1.12 · black 18.83
--base-light
#bfdff3 · white 1.39 · black 15.06
--base-semi-light
#69b4e2 · white 2.28 · black 9.23
--base-semi-dark
#1d6796 · white 6.12 · black 3.43
--base-dark
#144a6b · white 9.45 · black 2.22
--base-ultra-dark
#081e2b · white 17.06 · black 1.23
--base-hover
#0b293b · white 15.06 · black 1.39
--neutral
--neutral
#000000 · white 21.00 · black 1.00
--neutral-ultra-light
#f2f2f2 · white 1.12 · black 18.76
--neutral-light
#d9d9d9 · white 1.41 · black 14.88
--neutral-semi-light
#a6a6a6 · white 2.43 · black 8.63
--neutral-semi-dark
#595959 · white 7.00 · black 3.00
--neutral-dark
#404040 · white 10.37 · black 2.03
--neutral-ultra-dark
#1a1a1a · white 17.40 · black 1.21
--neutral-hover
#000000 · white 21.00 · black 1.00
--danger
--danger
#dc3546 · white 4.53 · black 4.64
--danger-ultra-light
#fbe9eb · white 1.17 · black 17.96
--danger-light
#f4bec3 · white 1.62 · black 13.00
--danger-semi-light
#e46774 · white 3.24 · black 6.49
--danger-semi-dark
#981b27 · white 8.33 · black 2.52
--danger-dark
#410b11 · white 16.40 · black 1.28
--danger-ultra-dark
#2b080b · white 18.38 · black 1.14
--danger-hover
#e25b68 · white 3.54 · black 5.93
Each swatch is one div with style="--swatch: var(--token)". The .swatch__chip chip reads background: var(--swatch) and knows nothing about any specific colour, which is how thirty-two swatches cost one class instead of thirty-two.
Four things the ramp does that will surprise you
- The unmodified token is not the middle of its ramp.
--basemeasures 15.97 on white, darker than--base-darkat 9.45. Reach for--baseexpecting a mid-tone and you get near-black. --neutralis pure black, and so is--neutral-hover.The neutral role has no mid tone under its bare name;--neutral-semi-dark(7.00 on white) is the one you usually want.- The danger role bottoms out early.
--danger-darkis #410b11 and--danger-ultra-darkis #2b080b, both effectively black. The usable dark red is--danger-semi-dark,#981b27, 8.33 on white — that is what this page uses to flag failures. - Hover direction is per role, not global.
--primary-hoveris darker than--primary;--base-hoverand--danger-hoverare lighter than theirs;--neutral-hoveris identical to--neutral.Do not assume a hover token moves in a predictable direction.
The tokens that change with the band
Seven of the framework tokens exist as a dark/light pair. Put an element inside .bg--dark or .bg--ultra-dark and ACSS swaps the half for you — the element needs no modifier and no class. This is the single most useful thing in ACSS 4 and the reason a well-written chip works on all four bands. Two more tokens look like they should be pairs and are not; they are the last two rows.
| Token | On the light grounds | On the dark grounds | How you get it |
|---|---|---|---|
| body text | --text-dark · #404040 | --text-light · #ffffff | automatic — no class |
| muted text | --text-dark-muted · #404040 at 80% | --text-light-muted · #ffffff at 80% | .text--dark-muted / .text--light-muted, or a chip that re-points |
| headings | --bg-ultra-light-heading and friends | --bg-dark-heading and friends | automatic — no class |
| border | --border-color-dark · #000000 at 20% | --border-color-light · #ffffff at 20% | border: var(--border), then --border: var(--border-light) in the dark context |
| divider | --divider-color-dark | --divider-color-light | the same shape as border |
| icon | --bg-ultra-light-icon | --bg-dark-icon | Color Assignments → Icon Scheme |
| button | --bg-ultra-light-button | --bg-dark-button | Color Assignments → Button Style |
| link | --link-color · #33a2c1 | --link-color · #33a2c1 — no pair | nothing to set per band today; see below |
| focus ring | --focus-color · #33a2c1 | --focus-color · #33a2c1 — no pair | nothing to set per band today |
The gap: links and focus have no contextual pair
--link-color resolves to --primary on every band. Measured against the five grounds it gives 2.97, 2.65, 2.10, 3.50 and 5.87 — four of the five below AA. This is a real defect in the template and it is written up with its fix in the accessibility section at the bottom. Nothing on this page relies on it: there is not a single prose link in the body of this page.
Writing a chip that survives all four bands
The pattern used by every custom chip on this page: declare the property against a local variable with the light-ground token as its fallback, then re-point only that variable in the dark context. The .matrix__flag chip on this page is four lines long and is the same colour logic ACSS uses internally.
bg--ultra-light.bg--ultra-light
Body copy on this band. Nothing here carries a colour class — the band supplies it.
Muted copy on this band, set with the matching muted utility.
A box using border: var(--border). The chip names no colour; the band decides which half of the border pair applies.
--border-color-dark, #000000 at 20% — 1.60 against the band
Measured on this band — body 9.26 · muted 5.32 · kicker 7.41. All clear AA.
bg--light.bg--light
Body copy on this band. Nothing here carries a colour class — the band supplies it.
Muted copy on this band, set with the matching muted utility.
A box using border: var(--border). The chip names no colour; the band decides which half of the border pair applies.
--border-color-dark, #000000 at 20% — 1.58 against the band
Measured on this band — body 7.35 · muted 4.55 · kicker 5.88. All clear AA.
bg--dark.bg--dark
Body copy on this band. Nothing here carries a colour class — the band supplies it.
Muted copy on this band, set with the matching muted utility.
A box using border: var(--border). The chip names no colour; the band decides which half of the border pair applies.
--border-color-light, #ffffff at 20% — 1.81 against the band
Measured on this band — body 10.37 · muted 7.33 · kicker 7.83. All clear AA.
bg--ultra-dark.bg--ultra-dark
Body copy on this band. Nothing here carries a colour class — the band supplies it.
Muted copy on this band, set with the matching muted utility.
A box using border: var(--border). The chip names no colour; the band decides which half of the border pair applies.
--border-color-light, #ffffff at 20% — 1.90 against the band
Measured on this band — body 17.40 · muted 11.42 · kicker 13.14. All clear AA.
Contrast matrix
Every ratio measured on this template at a 1440px viewport, converted from oklch to sRGB and composited where the token carries alpha. Read a row as: if I set text in this colour, on which of the five grounds does it clear 4.5:1?
The first three rows are what ACSS gives you automatically. Everything below them is a palette shade used as text, which is a decision you make and therefore a number you have to check. The border row is marked n/a because a decorative boundary is not text and is not held to 4.5:1 — WCAG asks 3:1 only of a UI boundary that carries meaning, and ACSS default borders at roughly 1.6:1 are decorative.
| Colour used as text | Page (white) | bg--ultra-light | bg--light | bg--dark | bg--ultra-dark |
|---|---|---|---|---|---|
| body text (auto) | 10.37 | 9.26 | 7.35 | 10.37 | 17.40 |
| muted text (auto) | 5.72 | 5.32 | 4.55 | 7.33 | 11.42 |
| border colour (auto) | 1.61 (n/a) | 1.60 (n/a) | 1.58 (n/a) | 1.81 (n/a) | 1.90 (n/a) |
--primary | 2.97 fail | 2.65 fail | 2.10 fail | 3.50 fail | 5.87 |
--primary-semi-light | 1.98 fail | 1.77 fail | 1.40 fail | 5.24 | 8.79 |
--primary-light | 1.32 fail | 1.18 fail | 1.07 fail | 7.83 | 13.14 |
--primary-semi-dark | 5.11 | 4.57 | 3.62 fail | 2.03 fail | 3.40 fail |
--primary-dark | 8.30 | 7.41 | 5.88 | 1.25 fail | 2.10 fail |
--primary-hover | 4.37 fail | 3.90 fail | 3.09 fail | 2.37 fail | 3.98 fail |
--base | 15.97 | 14.27 | 11.31 | 1.54 fail | 1.09 fail |
--base-light | 1.39 fail | 1.25 fail | 1.01 fail | 7.44 | 12.49 |
--base-semi-light | 2.28 fail | 2.03 fail | 1.61 fail | 4.56 | 7.65 |
--base-semi-dark | 6.12 | 5.46 | 4.33 fail | 1.69 fail | 2.84 fail |
--base-dark | 9.45 | 8.44 | 6.70 | 1.10 fail | 1.84 fail |
--danger | 4.53 | 4.04 fail | 3.21 fail | 2.29 fail | 3.85 fail |
--danger-semi-dark | 8.33 | 7.44 | 5.90 | 1.24 fail | 2.09 fail |
--danger-light | 1.62 fail | 1.44 fail | 1.14 fail | 6.42 | 10.77 |
--neutral-semi-dark | 7.00 | 6.26 | 4.96 | 1.48 fail | 2.48 fail |
--neutral-semi-light | 2.43 fail | 2.17 fail | 1.72 fail | 4.26 fail | 7.15 |
What the matrix says in one sentence
- No single primary shade works everywhere.
--primary-darkclears AA on all three light grounds (8.30 / 7.41 / 5.88) and fails both dark ones;--primary-lightis the mirror image (7.83 / 13.14 dark, 1.32 / 1.18 / 1.07 light). That is why a branded accent must be a contextual chip and not a shade in the class name. --primaryitself is not a text colour. It clears AA on exactly one of the five grounds,--bg-ultra-dark,at 5.87. Use it for fills, not for words.--primary-semi-darkis the near miss that catches people: 5.11 and 4.57 on the first two grounds, then 3.62 on.bg--light.A shade that passes on two light grounds and fails on the third is worse than one that fails everywhere, because it looks fine wherever you happened to test it.- The automatic pairs never fail. Body text, muted text and heading colour clear AA on all five grounds without a single class. The failures on this page are all in colours somebody chose by hand.
Three AA failures in this template, and the one-line fixes
Found by walking every rendered text node on the template pages, resolving the real background by climbing the ancestors, and compositing alpha before measuring. None of the three is rendered live anywhere on this page.
1. The solid primary button measures 2.71
.btn--primary paints --primary-ultra-light (#ebf7fa) on --primary (#33a2c1). That is 2.71:1. It is the default call-to-action shape, so it is on every page of every site cloned from this template — the header CTA, the footer CTA, and the skip link all inherit it.
| Option | Change | Result | Blast radius |
|---|---|---|---|
| A — recolour the button text | Primary button text token to --primary-ultra-dark (#0a2229) | 5.56 — passes | One token. The brand colour is untouched. |
| A2 — same, cooler | Primary button text token to --base-ultra-dark (#081e2b) | 5.75 — passes | One token. |
| B — darken the brand | --primary from oklch lightness 0.664 down to 0.52 or below | 4.78 at 0.52, 5.18 at 0.50 | The whole primary ramp moves. Every site changes appearance. |
| Do nothing | — | 2.71 fail | Every CTA in the fleet stays below AA. |
Recommended: option A. It is one token, it keeps the brand, and it is inherited by every clone.
2. Body links have no contextual pair and fail on four of five grounds
--link-color resolves to --primary everywhere. Color Assignments has a Link Color field per relationship group, but both groups are currently emitting the same value, so a link measures 2.97 on the page ground, 2.65 on ultra-light, 2.10 on light, 3.50 on dark and 5.87 on ultra-dark. The footer nav links on this site are the visible instance, at 2.65.
| Set this | To this | Gives |
|---|---|---|
| Color Assignments → Light Relationships → Link Color | primary-dark | 8.30 on the page ground, 7.41 on ultra-light, 5.88 on light |
| Color Assignments → Dark Relationships → Link Color | primary-light | 7.83 on dark, 13.14 on ultra-dark |
Those two settings turn --link-color into a contextual pair and every link in the fleet passes with no markup change anywhere. This is the same shape as the Button Style fix made to this template earlier — and it is a settings change, so it wants a decision before it is made.
3. The kicker anti-pattern — now fixed
/acss-4-sections-1-col/ carried a .kicker--primary-semi-dark example labelled "hardcoded colour". The lesson was right; the delivery was not. At 3.40 on .bg--dark it was real text a real visitor could not read, which is a strange way to teach contrast.
Both that chip and .kicker--contextual have been retired. Neither was reusable: each was a BEM modifier scoped to one component that declared nothing but colour, so the same colour on a paragraph needed a second near-identical class. They are replaced by plain shade utilities — .text--primary-dark, .text--primary-light and .text--primary-semi-dark — each one line, each named for exactly the token it applies. This page uses .text--primary-dark on its light bands and .text--primary-light on its dark ones. Choosing the shade is the author’s job; the matrix above is how you choose it.
Everything else passes. The automatic pairs — body text, muted text, heading colour — clear AA on all five grounds, and every chip written for this page and the two before it inherits from them rather than naming a colour.
Chip audit
Thirty-one classes appear on this page. Nineteen are ACSS 4 utilities backed by empty stub chips. Twelve are authored CSS, and every line of that CSS is below. Twenty-eight of the thirty-one contain no literal value at all.
The six chips written for this page
| Chip | Body | Literals |
|---|---|---|
.text--primary-dark | color: var(--primary-dark); | 0 |
.text--primary-light | color: var(--primary-light); | 0 |
.text--primary-semi-dark | color: var(--primary-semi-dark); | 0 |
| .swatch-grid | display: grid; grid-template-columns: var(--grid-auto-4); gap: var(--grid-gap); | 0 |
| .swatch | display: grid; gap: var(--space-xs); | 0 |
| .swatch__chip | background: var(--swatch); block-size: var(--space-xxl); border-radius: var(--radius); border: var(--border); and --border: var(--border-light) in the dark contexts | 0 |
| .matrix | border-collapse: collapse; text-align: left; th and td get padding-block and padding-inline of var(--space-xs) and border-block-end: var(--border); th gets font-weight: var(--heading-font-weight); --border: var(--border-light) in the dark contexts | 0 |
| .matrix__flag | color: var(--flag-color, var(--danger-semi-dark)); font-weight: var(--heading-font-weight); and --flag-color: var(--danger-light) in the dark contexts | 0 |
The test
Strip the comments, strip every custom-property name, and look at what is left. If a digit survives, the chip contains a literal. The six chips above leave nothing behind. Note that stripping the property names matters: --grid-auto-4 and --space-xxl carry digits in their names and are not literals.
Three inherited chips do not pass, and are named here rather than hidden
| Chip | Literal | What it should be |
|---|---|---|
| .subtitle | font-size: calc(var(--text-m) * 1.15) and font-weight: 300 | A text size token between m and l, set in Typography → Text Sizes. ACSS ships no body font-weight variable, so the 300 is the case the rule allows — but it wants a project variable, not a repeated number. |
| .kicker | letter-spacing: 0.08em and font-weight: 500 | ACSS 4 ships exactly one letter-spacing variable, --btn-letter-spacing, and one weight variable, --heading-font-weight (700). Neither fits a kicker. This is the genuine "the variable is what is missing" case: define a project token and use it. |
.measure--s / .measure--m / .measure--l | 0 — moved to --measure-s/m/l | Fixed. The three 70ch / 90ch / 110ch literals moved into project variables on the Etch :root chip, and .section-intro--max-* became .measure--s/m/l: one declaration each, max-inline-size: var(--measure-*). ACSS 4 ships no measure token — --text-m-max-width and its siblings all default to 100% and are typography-scoped, so they could not carry this. The utility now composes onto any element, not just a section intro. |
That is the point of auditing rather than asserting. Six new chips are clean, three old ones are not, and now they are on a list instead of in the dark.