Naming Color Tokens Semantically
gray-500 dies on rebrand day. surface-raised does not.
Design systems that name colors after their appearance tend to age badly. A token called --blue-600 made perfect sense when the brand was blue. Sixteen months later, marketing shipped a green identity, and suddenly every engineer was grep-searching for hex values that were never supposed to be public API. The tokens that survived were the ones that described jobs: --text-primary, --surface-raised, --border-subtle. Those names did not change when the hue did.
Semantic naming is not a naming convention preference. It is how you keep text color maintainable when themes multiply, when Figma variables diverge from production CSS, and when a rebrand cannot take three sprints of find-and-replace archaeology. A semantic token answers what this color is for on the screen, which surfaces it sits on, and whether it is meant to be read at body size or only as a label. A presentational token answers what the swatch looks like in the brand palette right now.
MDN’s documentation on color describes the property as setting foreground text color. That is the mechanism. Semantic tokens are the vocabulary your team uses so color: var(--text-secondary) stays meaningful when --text-secondary stops being gray and starts being a desaturated teal. The CSS Custom Properties specification does not care what you name variables. Browsers will happily apply --banana to a heading. Your future self will not be happy.
What semantic text tokens encode
Role-based naming decouples intent from pigment. Text-primary carries body copy and headings. Text-secondary carries captions, meta lines, and helper text. Text-disabled carries non-interactive muted states that must not be used for essential instructions. Text-inverse carries copy on dark or saturated fills. Surface-default and surface-raised describe where text sits, not what gray the designer picked on a Tuesday.
WCAG 2.2 Success Criterion 1.4.3 still applies no matter how elegant your naming is. Semantic tokens make contrast fixes global: bump --text-secondary lightness once, re-run checks against default and raised surfaces, ship. Without roles, every usage of --gray-500 is treated as interchangeable medium gray, and QA discovers failures one screen at a time because the token name carried no intent about context.
Apply tokens at the right layer. Set defaults once at the document or layout root, then override at component boundaries where hierarchy changes. The mistake to avoid is sprinkling literal hex inside components just this once. Those exceptions become undeclared tokens. Within a month someone copies the hex into a chart tooltip, and the system has two unofficial muted grays that look identical until dark mode proves they are not.
:root {
--text-primary: oklch(0.22 0.02 260);
--text-secondary: oklch(0.55 0.02 260);
--text-disabled: oklch(0.72 0.01 260);
--surface-default: oklch(0.98 0.01 260);
--surface-raised: oklch(1 0 0);
}
body {
color: var(--text-primary);
background: var(--surface-default);
}
.card-meta {
color: var(--text-secondary);
}
When gray-500 survived the rebrand and contrast did not
A product dashboard from 2024 used --gray-900 for body copy, --gray-500 for secondary labels, and --gray-300 for disabled inputs. The palette lived in one root block and felt tidy until marketing shifted primary accent from blue-violet to green and warmed neutrals slightly. Engineering’s task was not change accent. It was re-tune every text relationship so hierarchy still read.
Presentational tokens appeared in forty-seven files: form hints, table headers, breadcrumb separators, chart legends, empty states. Some contexts needed four point five to one contrast against white. Others sat on tinted panels at OKLCH lightness zero point nine six where the same gray failed. Because --gray-500 named appearance, not role, every usage was treated as interchangeable medium gray.
The refactor started with a table design and engineering agreed on once, not with mass renaming in components. Roles were defined with purpose and typical contrast targets. Text-primary for body and headings against default surface. Text-secondary for captions against surface with explicit note that tinted panels may need scoped overrides. Text-disabled for decorative muted states only. Text-inverse for copy on accent or inverse surfaces.
| Role | Purpose | Contrast target |
|---|---|---|
| text-primary | Body copy, headings | WCAG AA on default surface |
| text-secondary | Captions, meta, helpers | AA on surface; verify on tinted panels |
| text-disabled | Non-interactive muted state | Decorative; not for essential text |
| text-inverse | Text on dark or saturated fills | AA on accent or inverse surface |
The table lived in the token repository beside the CSS definition block. Pull requests that introduced a new text role without a row failed review. Pull requests that changed OKLCH values without updating contrast notes failed review. The process felt heavy for two weeks and then became background noise because most feature work reused existing roles instead of inventing grays.
CSS expressed roles, not grays. When the rebrand landed, twelve files changed, mostly the token definition block and theme overrides. Components kept saying var(--text-secondary) because secondary still meant de-emphasized metadata. The hue underneath changed twice during review. The component layer never noticed.
Dark mode exposed the next failure mode. Muted text that looked fine at OKLCH zero point five five on white washed out at the same coordinate on charcoal surfaces. Semantic naming helped only after paired roles existed per theme. Secondary text lightened in dark mode rather than darkening, because perceptual brightness is not symmetric. Teams that reused the same OKLCH lightness for --text-secondary across themes failed captions while headlines passed.
:root {
--text-primary: oklch(0.22 0.02 260);
--text-secondary: oklch(0.55 0.02 260);
}
[data-theme="dark"] {
--text-primary: oklch(0.93 0.01 260);
--text-secondary: oklch(0.72 0.02 260);
}
The case study’s lesson was that semantic names buy you one coordinated update at the token layer, but only if you define paired values per theme and validate contrast on every surface a component can inhabit, not only on the default page background.
Anti-patterns, Figma alignment, and migration
Publishing palette names as public API makes --gray-400 and --slate-700 brittle once they leak into component CSS. External consumers of your design system bind to the wrong abstraction. Duplicating the same value under --text-muted and --gray-500 creates a false choice. Engineers pick whichever name autocomplete suggests. When contrast tuning requires different muted values on cards versus tables, neither name meant anything specific.
Encoding state in color names like --text-red-error tempts teams who want self-documenting CSS. Error state is better expressed as a field error class setting color: var(--text-danger) where text-danger is a role. States multiply. Red does not always mean error in every culture or brand. Hard-coding inverse text per component with literal white fails when accent shifts to yellow-green and contrast collapses. var(--text-on-accent) or computing on-accent text from the accent with relative color syntax keeps the relationship explicit.
Figma Variables encourage hierarchical paths like text/primary and surface/raised. CSS conventionally flattens to --text-primary and --surface-raised. Document the mapping once so designers do not invent text/muted-2 during a sprint without an engineering counterpart. For a full walkthrough of keeping Figma and CSS aligned through rebrands, see Figma Variables to CSS.
Icons complicate text color slightly. SVG icons often should track adjacent text without duplicating tokens. currentColor lets color: var(--text-secondary) on a parent flow into stroke and fill.
Migration rarely gets a quiet month. Freeze new presentational tokens in component pull requests. Introduce role tokens that alias old values so --text-primary: var(--gray-900) lets you swap the alias target later. Lint in CI blocking gray tokens in component directories. Convert high-traffic surfaces first: authentication, settings, billing. Delete aliases once usages hit zero. Run contrast checks on real font sizes, not token pairs in isolation. Secondary text that passes against surface-default may fail on surface-raised cards.
Scoped overrides are legitimate when hierarchy demands them. A secondary caption on a tinted marketing panel may need color: var(--text-secondary-on-tinted) that aliases a slightly darker OKLCH value than default secondary. The mistake is creating scoped overrides without naming them as roles. --text-secondary-on-tinted is a role with a documented surface pairing. #5b6472 on one card is an undeclared token that will duplicate by paste before the next sprint ends.
Prose-heavy products should wire heading levels through semantic roles rather than element selectors alone. h1 through h3 may all use text-primary while h4 labels use text-secondary when design hierarchy treats small caps labels as meta. Document those mappings in the same table as token roles so content editors and engineers agree which MDX or CMS classes map to which variables. Content systems that expose color pickers to authors should expose role names, not hex, even when the underlying implementation still stores hex in a database.
Internationalization and longer strings stress secondary text tokens more than English mockups predict. German labels in data tables may wrap to two lines while English mockups showed one. If secondary text was tuned to the minimum passing contrast, localized builds can fail without any code change. Leave margin in secondary token contrast during the role definition phase, especially for small sizes, rather than targeting the mathematical minimum in the primary locale only.
When a pull request changes text color, reviewers should ask whether the change touches a role token or a one-off hex, whether a new role is documented, whether contrast was verified on every surface the component can sit on, and whether dark mode received paired values rather than copy-paste. Semantic naming does not remove those questions. It makes the answers searchable. Find all text-secondary usages is a product decision. Find all instances of a specific hex is archaeology.
Typography and text color interact in ways semantic naming makes easier to reason about. A secondary text token at smaller sizes may need slightly higher lightness than the same role at caption sizes to preserve apparent hierarchy, not because the token is wrong but because perceived contrast depends on stroke weight and subpixel rendering. Document those exceptions as scoped rules rather than new tokens whenever possible. .prose .lead using var(--text-secondary) at a larger size is a hierarchy decision encoded in component structure, not a third gray in the palette.
Link text deserves explicit roles when default browser underlines and focus styles participate in the brand system. --text-link and --text-link-visited can alias accent tokens or diverge when accessibility requirements demand it. visited styling is easy to forget in token tables because marketing mockups rarely show it. Include visited pairs in the signed map alongside primary and secondary body roles so engineering does not improvise #6b7280 again during a rushed sprint.
Charts and data labels often need a dedicated --text-chart or --text-axis role even when the value equals --text-secondary initially. Visualization libraries frequently accept color arrays outside the component CSS cascade. Giving data viz its own semantic alias lets you tune chart legibility without touching form labels when product later asks for denser dashboards on dark backgrounds. The alias may point at the same OKLCH coordinate on day one. The naming reserve is what matters.
Review culture completes the system. When a pull request changes text color, reviewers should ask whether the change touches a role token or a one-off hex, whether a new role is documented in the token table, whether contrast was verified on every surface the component can sit on, and whether dark mode received paired values rather than copy-paste from light. Semantic naming does not remove those questions. It makes the answers searchable. Find all text-secondary usages is a product decision about de-emphasized copy everywhere. Find all instances of a specific hex is archaeology that returns forty-seven unrelated contexts and no owner.
Accessibility statements and VPAT documentation increasingly ask how text colors are maintained across themes. Semantic roles with paired light and dark values produce answers procurement can reuse year to year. A VPAT that says captions use text-secondary backed by documented contrast matrices is stronger than a VPAT that says gray five hundred, which tells auditors nothing after the next rebrand changes the gray ramp underneath the name.
Print stylesheets and PDF exports are easy to forget in semantic text systems. A user printing a settings page to PDF should still receive readable hierarchy if print CSS maps text-primary and text-secondary to high-contrast black and dark gray ink values. Web roles can alias to print-specific values in @media print without renaming components. The role name stays stable. The medium-specific value changes.
Email clients that strip external stylesheets still need a forked template layer, but semantic naming in the web repo helps generators map text-secondary to an inline hex computed from the same OKLCH seed. The email hex is not a second source of truth if a build script derives it from the token JSON both web and email consume.
Forced-colors and high-contrast modes may map semantic text roles to system colors for user safety. Document those mappings as explicit overrides rather than hoping brand tokens survive every platform high-contrast palette unchanged. Semantic naming survives rebrands. System overrides survive user agent constraints. Both belong in the same token specification document with different sections so neither concern is forgotten during the next release cycle.
Gray-500 dies on rebrand day because it was never describing a job, only a swatch. Name text colors by the work they do on screen, keep pigment in one place, and the next identity refresh becomes a token update instead of a quarter-long refactor.