# Components should reference tokens, not values > A hard-coded value in a component is a bug. How to take a button apart and put every property, states included, on tokens. - Source: https://flashdsm.com/blog/components-reference-tokens - Published: 2026-09-11 - Author: Flash DSM Team - Track: Fundamentals - Reading time: 5 min read --- ## Anatomy first Before you can put a component on tokens, you need a list of its parts. Take a button apart and you'll find: - **Container**: background, border, radius, shadow. - **Label**: font family, size, weight, line height, colour. - **Icon** (optional): size, colour, gap to the label. - **Padding**: inline and block, sometimes asymmetric when there's an icon. - **Focus indicator**: colour, width, offset. Every one of those is a decision somebody made once. Anatomy just writes them down so each can point at a token instead of being typed into the stylesheet. A button has maybe twelve of these properties. A data table has sixty. The method is the same. A useful test: if you can't name a property's part, you can't tokenize it. "The grey line under the header" is a border on the header region, and now it can be `--border-subtle` rather than `#e4e4e7`. ## Component tokens or semantic tokens There are two ways to connect a component to the system. **Use semantic tokens directly.** The button's background is `var(--color-primary)`, its label is `var(--color-on-primary)`. Simple, and for most components in most systems, enough. **Add a component token layer.** The button reads `var(--button-bg)`, and `--button-bg` is defined as `var(--color-primary)`. One more hop, one more name. | | Semantic directly | Component tokens | |---|---|---| | Names to maintain | Few | Many, per component | | Retheme one component | Edit the component's CSS | Change one alias | | Risk | Two components that should differ share a token | Token sprawl nobody reads | | Good for | Small systems, early days | Multi-brand, white-label, big teams | Our advice: start with semantic tokens and add component tokens only when you have an actual reason, like a brand that needs its buttons to diverge from its links while everything else stays shared. A component token that only ever aliases one semantic token, and always will, is just a longer name. Whichever you choose, the rule is the same. A component never points at a primitive. `--blue-600` in a button's CSS means dark mode, high contrast and the next rebrand all have to find that line by hand. [Primitive vs semantic colour tokens](/blog/primitive-vs-semantic-colour-tokens) covers why the layer exists. ## States are tokens too Most systems tokenize the resting state carefully and then write hover as `filter: brightness(0.9)` or `#2f5ce0`, typed in once and never looked at again. States are where hard-coded values pile up, because they're the part people add last. Treat each state as its own set of values: | State | Selector | What usually changes | |---|---|---| | Default | `.button` | Everything is defined here | | Hover | `:hover` | Background one ramp step darker (lighter in dark mode) | | Pressed | `:active` | One more step, sometimes a smaller shadow | | Focus visible | `:focus-visible` | A ring, nothing else | | Disabled | `:disabled`, `[aria-disabled="true"]` | Muted background and label, not-allowed cursor | - **Hover and pressed come from the same ramp.** If default is step 500, hover is 600 and pressed is 700. A ramp gives you these for free, and they stay related when the hue changes. - **Focus is its own state, not a hover variant.** It uses `:focus-visible` so mouse users don't get a ring on every click, and keyboard users always do. - **Disabled doesn't need to meet contrast, but it needs to be recognisable.** WCAG exempts inactive components from contrast requirements. That's a floor, not a design goal. Don't use `opacity: 0.4` on the whole button, which also fades the focus ring and whatever sits behind it. Use dedicated muted tokens. - **Name the state in the token, not the colour.** `--color-primary-hover`, not `--color-primary-darker`. In dark mode the hover might be lighter, and the name should still be true. ## Focus rings that pass 3:1 A focus indicator is the visual information that shows a component's state, so [success criterion 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) applies: at least 3:1 against the colours next to it. A ring has two neighbours, though: the component it surrounds and the page behind it. A 2px ring in the brand colour, sitting right against a brand-coloured button, has roughly 1:1 contrast with the button. It only works if the ring is offset so the page background shows through between them. So the pattern most systems end up with is: - A dedicated `--color-focus` token that reaches 3:1 against every surface it can appear on, in both themes. - `outline-offset: 2px`, so the gap takes on the page colour and the ring never touches the fill. - `outline`, not `box-shadow`. Windows High Contrast mode drops box-shadows and keeps outlines. - A width token, usually 2px. Anything under 2px is easy to miss. Check the focus colour against your lightest and darkest surfaces, not just the default page. The raised card in dark mode is where rings usually fail. [Contrast in practice](/blog/contrast-wcag-aa-in-practice) has the arithmetic. ## A hard-coded value is a bug `padding: 12px` in a component looks like a style choice. Treat it as a bug, the same kind as a hard-coded API URL: it works today and silently stops being true the next time anything changes. 1. **Theme changes skip it.** Dark mode swaps the tokens. `#ffffff` stays white on a dark page. 2. **Scale changes skip it.** You move to an 8px grid and 12px is now an orphan that doesn't line up with anything. 3. **Nobody can find it.** Search for `--space-3` and you get every usage. Search for `12px` and you get font sizes, line heights, radii and three media queries. Lint for raw hex, rgb and px values in component files the way you lint for unused variables. Stylelint's `color-no-hex` rule and the `stylelint-declaration-strict-value` plugin do this in a few lines of config. When you do need a one-off, write a comment saying why, so the next person knows it's deliberate. ## A button, fully on tokens Here is a primary button with every value coming from a token. No hex and no px: even the 1px hairline border is a token. ```css .button { display: inline-flex; align-items: center; gap: var(--space-2); padding-block: var(--space-2); padding-inline: var(--space-4); border: var(--border-width-thin) solid transparent; border-radius: var(--radius-md); background: var(--color-primary); color: var(--color-on-primary); font-family: var(--font-body); font-size: var(--text-sm); font-weight: var(--font-weight-medium); line-height: var(--leading-tight); transition: background-color var(--duration-fast) var(--ease-standard); } .button:hover { background: var(--color-primary-hover); } .button:active { background: var(--color-primary-pressed); } .button:focus-visible { outline: var(--focus-width) solid var(--color-focus); outline-offset: var(--focus-offset); } .button:disabled, .button[aria-disabled="true"] { background: var(--color-disabled-bg); color: var(--color-disabled-fg); cursor: not-allowed; } @media (prefers-reduced-motion: reduce) { .button { transition: none; } } ``` Switch the theme and every line re-resolves. Move to a denser spacing scale and the padding follows. Swap the brand hue and hover and pressed follow along, because they point at the same ramp. ## How Flash does it In Flash, the Components view edits each component per state (default, hover, pressed, focus and disabled), and every property is bound to a token from your foundations rather than typed in. Change a colour in Foundations and every component that references it updates in the preview, in both themes, and in whatever you export next. ## Checklist for your next component - List the anatomy before you write CSS. - Every property points at a semantic or component token. None point at a primitive. - Hover, pressed, focus-visible and disabled each have their own tokens. - The focus ring reaches 3:1 against every surface it can land on, with an offset. - Lint rejects raw values in component files.