States
Every interactive element is in one of these states at any given moment – at rest, hovered, focused, pressed, selected, disabled, read-only, or loading. Consistency across the system matters more than novelty in any one place. A button that signals focus the same way as a list row, an input, and a tab is a button you can use without thinking.
The focus ring
One ring across the system. Teal-600 stroke at 2 px, sitting outside a 2 px paper-coloured halo so it stays legible on cream, navy, or a photograph. It is set globally on a, button, input, select, textarea, [tabindex] via :focus-visible – it lights up for keyboard users and stays out of the way when a mouse clicks.
The ring is brand-locked teal because focus is a single, system-wide signal. A second focus colour anywhere in the product would force users to learn two visual languages for the same idea.
Tab through this row – the ring appears. Click any of them with a mouse and it will not. That is :focus-visible doing its job.
The token is a composed box-shadow so it works on any element without changing layout.
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: var(--es-focus-ring);
}
--es-focus-ring:
0 0 0 var(--es-focus-ring-offset) var(--es-color-paper),
0 0 0 calc(var(--es-focus-ring-offset) + var(--es-focus-ring-width)) var(--es-focus-ring-color);
Hover
Hover is a tonal shift, never a hue swap. On light surfaces, overlay --es-state-hover-on-light – ink at 6%. On dark surfaces, overlay --es-state-hover-on-dark – white at 10%. The colour identity of the element stays exactly where it was.
Pressed
Pressed deepens the hover tint and pulls the element 1 px down. --es-state-pressed-on-light is ink at 12%; --es-state-pressed-on-dark is white at 18%. The state lives only while the pointer or key is held – on release, the element returns to hover or rest.
Selected
Selected marks “this is the active item in a set” – the current row, the open tab, the chosen option. Three tokens carry it: --es-state-selected-bg (teal at 8%), --es-state-selected-border (teal-600), --es-state-selected-text (teal-900). Weight bumps to 600 so the row reads as anchored.
Selected is a state, not a press. A selected item stays selected when you move your cursor away. A pressed one snaps back the moment you let go.
Disabled
Disabled controls stay visible. The user needs to know the action exists – it just isn’t available right now. Hide it and they’ll wonder where it went; grey it to the point of illegibility and they’ll think the whole interface is broken.
--es-state-disabled-opacity (0.40) dims the whole element on brand-coloured surfaces. For form chrome, --es-state-disabled-bg, --es-state-disabled-text, and --es-state-disabled-border paint the parts directly.
Use the native disabled attribute on form controls – it takes the element out of the tab order and announces correctly to screen readers. For non-form elements that need to look disabled, use aria-disabled="true" so assistive tech still sees them, then guard the click handler. Never style something to look disabled while leaving it focusable and clickable.
Read-only
Read-only is not disabled. The value matters, the user just can’t edit it – an account number, a calculated total, a publication date. Show the content at full contrast on --es-state-readonly-bg (stone-50) so it reads as informational, not non-actionable.
Loading
Loading uses --es-state-loading-shimmer for skeleton placeholders that mirror the shape of the incoming content. Skeletons reserve the layout and stop the page from collapsing and re-expanding around the user. An inline spinner is fine for a single button mid-submit – never replace the whole UI on every fetch.
If a load takes longer than a second, show progress. Under 200 ms, no indicator at all – the eye doesn’t need one.
State combination matrix
How states stack across common controls. Empty cells are by design – the combination doesn’t trigger or doesn’t apply.
| Control | Hover | Focus-visible | Pressed | Selected | Disabled | Read-only | Loading |
|---|---|---|---|---|---|---|---|
| Button | Tonal tint | Ring | Tint + 1px down | n/a | 40% opacity | n/a | Inline spinner |
| Link | Darker teal | Ring | Darker still | n/a | 40% opacity | n/a | n/a |
| Input | Border darkens | Teal border + ring | n/a | n/a | Disabled chrome | Stone-50 bg | Skeleton or spinner |
| List row | Hover tint | Ring | Pressed tint | Teal tint + border | 40% opacity | n/a | Skeleton row |
| Tab | Hover tint | Ring | n/a | Teal underline | 40% opacity | n/a | n/a |
| Checkbox | Border darkens | Ring | Tint | Teal fill + tick | Disabled chrome | Disabled chrome | n/a |
Usage rules
The ring lights up for keyboard users and stays quiet for mouse clicks. The system sets this globally – never disable it on a per-component basis.
outline: none with no replacement fails WCAG 2.4.7. Keyboard users lose their place entirely and can’t recover it without clicking.
Hover stays inside the same hue – darker or lighter, never a different colour. The element’s identity holds.
Navy to orange on hover reads as “this is now a different button”. Surprising, and worse, ambiguous about intent.
A disabled control still tells the user the action exists. Hidden controls force them to discover features by accident.
“Disabled” is not a license for illegibility. The user still needs to read what they cannot do.
A skeleton holds the shape of the incoming content. Under 200 ms show nothing; over 1 s show progress, not just motion.
Loading the whole page…
A full-page spinner on every action collapses the layout and erases context. Reserve it for first paint, never for a button click.
Selected sits on --es-state-selected-bg with the teal border and bumped weight. It survives mouse-out – pressed does not.
A “selected” row built from pressed styling looks like the user is still mid-click. Use the selected tokens.
Tokens to ship
| Token | Value | Intent |
|---|---|---|
| —es-focus-ring | composed shadow | The ready-to-use box-shadow for any focusable element. Always paired with :focus-visible. |
| —es-focus-ring-color | #06A0A9 | Brand-locked teal-600. The single focus colour across the system. |
| —es-focus-ring-width | 2px | Stroke thickness, sized for AA contrast at standard zoom. |
| —es-focus-ring-offset | 2px | Paper-coloured halo between element and ring – legible against any surface. |
| —es-state-hover-on-light | rgba(8, 3, 5, 0.06) | Ink at 6% – overlay tint for hover on light surfaces. |
| —es-state-hover-on-dark | rgba(255, 255, 255, 0.10) | White at 10% – overlay tint for hover on dark surfaces. |
| —es-state-pressed-on-light | rgba(8, 3, 5, 0.12) | Ink at 12% – overlay tint for pressed on light surfaces. |
| —es-state-pressed-on-dark | rgba(255, 255, 255, 0.18) | White at 18% – overlay tint for pressed on dark surfaces. |
| —es-state-selected-bg | rgba(6, 160, 169, 0.08) | Teal-600 at 8% – background for the active item in a list or tab set. |
| —es-state-selected-border | #06A0A9 | Teal-600 – border or indicator colour for selected. |
| —es-state-selected-text | #044C51 | Teal-900 – text on selected-bg, AA compliant. |
| —es-state-disabled-bg | #F6F3EC | Stone-100 – disabled control background. |
| —es-state-disabled-text | #B9B7B0 | Stone-400 – disabled text. Below AA by design, to signal non-actionable. |
| —es-state-disabled-border | #E7E5DD | Stone-200 – disabled border. |
| —es-state-disabled-opacity | 0.40 | Whole-element dim for disabled brand-coloured controls. |
| —es-state-readonly-bg | #FCFAF3 | Stone-50 – read-only field background. Text stays at full contrast. |
| —es-state-loading-shimmer | linear-gradient | Skeleton shimmer overlay, 1.6 s ease-in-out loop. |