# Controls Get Their Own Tokens

**SHOULD** · **ID:** `design-interface-control-tokens` · **Category:** design
**Source:** [interface-design](https://github.com/Dammyjay93/interface-design)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-interface-control-tokens

> SHOULD: Declare `--control-bg`, `--control-border` and `--control-focus` as their own trio — do not bind inputs, selects and checkboxes to the surface/card tokens. A card is a surface that RECEIVES the eye and should recede; an input must announce itself as a place where something goes, and if it shares the card's background and the divider's border its boundary lands near 1.2:1 and the field becomes a rumour. Separable tokens are what let you meet `design-non-text-contrast` (WCAG 1.4.11, 3:1 for the boundary that identifies a control) without shouting every divider on the page. On dark, inputs read best slightly DARKER than their surroundings — an inset well says "type here" without chrome.

Give inputs, checkboxes and selects dedicated background, border and focus tokens instead of reusing the card and background tokens

This is why form fields disappear into the page. A card token and a control token are asked to do opposite jobs: a card is a surface that RECEIVES the eye and should recede, while an input is a surface that INVITES input and must announce itself as a place where something goes. Bind `--control-bg` to `--card` and the field is exactly as bright as the panel it sits in, so the only thing left marking it as a field is its border — and if that border is also the card's `--border`, tuned to be a quiet divider, the boundary lands near 1.2:1 and the input becomes a rumour. Upstream's own advice sharpens the point: on dark, inputs should be slightly DARKER than their surroundings, not lighter, because an inset well reads as "type here" without heavy chrome — a move you simply cannot make while the control shares the surface token. Declare `--control-bg`, `--control-border` and `--control-focus` as their own trio, and tune them against one hard floor: design-non-text-contrast (WCAG 1.4.11) requires 3:1 for the visual boundary that identifies a control, measured against the surface actually behind it. That floor is the reason the tokens must be separable — you cannot raise the input border to 3:1 without also shouting every divider on the page, unless the two are different tokens.

## Bad — do not do this

`design-interface-control-tokens-bad`

```tsx
/**
 * Bad: the form fields are painted from the LAYOUT tokens. The input background is the
 * card background, and the input border is the same `--border` that draws dividers —
 * a token deliberately tuned to be quiet. So the field is exactly as bright as the panel
 * it sits in, its only boundary is a whisper-hairline, and the input becomes a rumour.
 */
export function InterfaceControlTokensBad() {
  return (
    <div className="w-full max-w-sm space-y-3">
      <p className="text-xs text-muted-foreground">Controls painted from surface tokens</p>

      <form className="space-y-3 rounded-lg border border-border bg-card p-4" onSubmit={(e) => e.preventDefault()}>
        <div className="space-y-1">
          <label htmlFor="bad-project" className="block text-xs font-medium text-foreground">
            Project name
          </label>
          {/* bg-card === the panel behind it; border-border === the divider token */}
          <input
            id="bad-project"
            type="text"
            defaultValue="atlas-web"
            className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground"
          />
        </div>

        <label className="flex items-center gap-2 text-sm text-foreground">
          <input type="checkbox" defaultChecked className="size-4 accent-muted" />
          Deploy on push
        </label>

        {/* The divider uses the SAME token as the input border, which is the whole problem */}
        <hr className="border-border" />
        <p className="text-xs text-muted-foreground">Changes apply to the next build.</p>
      </form>

      <div className="rounded bg-muted p-2 font-mono text-xs">
        <code className="text-error">--input-bg = --card &nbsp;·&nbsp; --input-border = --border</code>
      </div>

      <p className="text-xs text-error">
        A card should RECEIVE the eye and recede; an input should INVITE input and announce itself.
        Bind them to one token and the field cannot do its job. And you cannot fix it by darkening
        <code className="mx-1 font-mono">--border</code>: that same token draws the divider below, so
        every quiet line on the page would shout with it.
      </p>
    </div>
  );
}
```

## Good — do this

`design-interface-control-tokens-good`

```tsx
import type { CSSProperties } from 'react';

/**
 * Good: controls get their own trio — `--control-bg`, `--control-border`,
 * `--control-focus` — declared independently of the surface tokens. Because they are
 * separable, the control boundary can be raised to clear the 3:1 non-text-contrast
 * floor (WCAG 1.4.11) WITHOUT dragging every divider on the page up with it.
 *
 * The dark panel shows the move you cannot make while sharing a surface token: an
 * input that is slightly DARKER than its surroundings reads as an inset well.
 */
const lightControls = {
  '--surface': 'oklch(0.99 0.002 250)',
  '--ink': 'oklch(0.22 0.010 250)',
  '--divider': 'oklch(0.92 0.004 250)', // quiet: dividers should recede
  '--control-bg': 'oklch(1 0 0)',
  '--control-border': 'oklch(0.68 0.012 250)', // loud enough to clear 3:1 on the surface
  '--control-focus': 'oklch(0.55 0.190 259)',
} as CSSProperties;

const darkControls = {
  '--surface': 'oklch(0.24 0.006 250)',
  '--ink': 'oklch(0.95 0.005 250)',
  '--divider': 'oklch(0.34 0.008 250)',
  '--control-bg': 'oklch(0.18 0.006 250)', // DARKER than its surroundings: an inset well
  '--control-border': 'oklch(0.48 0.012 250)',
  '--control-focus': 'oklch(0.72 0.150 259)',
} as CSSProperties;

function Panel({ tokens, label }: { tokens: CSSProperties; label: string }) {
  const id = `control-tokens-${label.toLowerCase()}`;
  return (
    <form
      className="space-y-3 rounded-lg bg-[var(--surface)] p-4 text-[var(--ink)]"
      style={tokens}
      onSubmit={(e) => e.preventDefault()}
    >
      <div className="space-y-1">
        <label htmlFor={id} className="block text-xs font-medium">
          {label} &middot; project name
        </label>
        <input
          id={id}
          type="text"
          defaultValue="atlas-web"
          className="w-full rounded-md border border-[var(--control-border)] bg-[var(--control-bg)] px-3 py-2 text-sm text-[var(--ink)] outline-[var(--control-focus)] focus-visible:outline-2 focus-visible:outline-offset-1"
        />
      </div>

      <label className="flex items-center gap-2 text-sm">
        <input type="checkbox" defaultChecked className="size-4 accent-[var(--control-focus)]" />
        Deploy on push
      </label>

      {/* The divider keeps its own quiet token — untouched by the control boundary */}
      <hr className="border-[var(--divider)]" />
    </form>
  );
}

export function InterfaceControlTokensGood() {
  return (
    <div className="w-full space-y-3">
      <p className="text-xs text-muted-foreground">Controls tuned independently of surfaces</p>

      <div className="grid gap-3 sm:grid-cols-2">
        <Panel tokens={lightControls} label="Light" />
        <Panel tokens={darkControls} label="Dark" />
      </div>

      <div className="space-y-1 rounded bg-muted p-2 font-mono text-xs">
        <code className="block">--control-bg / --control-border / --control-focus</code>
        <code className="block">--divider stays quiet &mdash; a different token entirely</code>
      </div>

      <p className="text-xs text-success">
        Tab into a field: focus is drawn from <code className="font-mono">--control-focus</code>, not
        borrowed from a surface. The control border is loud enough to clear the 3:1 boundary floor of
        <code className="mx-1 font-mono">design-non-text-contrast</code> while the divider stays a whisper,
        which is only possible because they are separate tokens. And on dark, the input is DARKER than
        its panel &mdash; an inset well that says &ldquo;type here&rdquo; without heavy chrome.
      </p>
    </div>
  );
}
```

## References

- [interface-design (Damola Akinleye)](https://github.com/Dammyjay93/interface-design)
- [Understanding SC 1.4.11: Non-text Contrast](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast.html)
