Skip to main content
Executive Support by Beige Threat

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.

Read more

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.

Hover on dark
—es-state-hover-on-dark · rgba(255, 255, 255, 0.10)

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.

Pressed on dark
—es-state-pressed-on-dark · rgba(255, 255, 255, 0.18)

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.

ControlHoverFocus-visiblePressedSelectedDisabledRead-onlyLoading
ButtonTonal tintRingTint + 1px downn/a40% opacityn/aInline spinner
LinkDarker tealRingDarker stilln/a40% opacityn/an/a
InputBorder darkensTeal border + ringn/an/aDisabled chromeStone-50 bgSkeleton or spinner
List rowHover tintRingPressed tintTeal tint + border40% opacityn/aSkeleton row
TabHover tintRingn/aTeal underline40% opacityn/an/a
CheckboxBorder darkensRingTintTeal fill + tickDisabled chromeDisabled chromen/a

Usage rules

Do · Use :focus-visible

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.

Don't · Strip the ring without a replacement

outline: none with no replacement fails WCAG 2.4.7. Keyboard users lose their place entirely and can’t recover it without clicking.

Do · Tonal shifts on hover

Hover stays inside the same hue – darker or lighter, never a different colour. The element’s identity holds.

Don't · Hue swaps on hover

Navy to orange on hover reads as “this is now a different button”. Surprising, and worse, ambiguous about intent.

Do · Disabled stays visible

A disabled control still tells the user the action exists. Hidden controls force them to discover features by accident.

Don't · Grey-on-grey to the point of unreadable

“Disabled” is not a license for illegibility. The user still needs to read what they cannot do.

Do · Show progress past a second

A skeleton holds the shape of the incoming content. Under 200 ms show nothing; over 1 s show progress, not just motion.

Don't · Replace the whole UI on every fetch

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.

Do · Selected is its own state

Selected sits on --es-state-selected-bg with the teal border and bumped weight. It survives mouse-out – pressed does not.

Don't · Pressed-as-selected

A “selected” row built from pressed styling looks like the user is still mid-click. Use the selected tokens.

Tokens to ship

TokenValueIntent
—es-focus-ringcomposed shadowThe ready-to-use box-shadow for any focusable element. Always paired with :focus-visible.
—es-focus-ring-color#06A0A9Brand-locked teal-600. The single focus colour across the system.
—es-focus-ring-width2pxStroke thickness, sized for AA contrast at standard zoom.
—es-focus-ring-offset2pxPaper-coloured halo between element and ring – legible against any surface.
—es-state-hover-on-lightrgba(8, 3, 5, 0.06)Ink at 6% – overlay tint for hover on light surfaces.
—es-state-hover-on-darkrgba(255, 255, 255, 0.10)White at 10% – overlay tint for hover on dark surfaces.
—es-state-pressed-on-lightrgba(8, 3, 5, 0.12)Ink at 12% – overlay tint for pressed on light surfaces.
—es-state-pressed-on-darkrgba(255, 255, 255, 0.18)White at 18% – overlay tint for pressed on dark surfaces.
—es-state-selected-bgrgba(6, 160, 169, 0.08)Teal-600 at 8% – background for the active item in a list or tab set.
—es-state-selected-border#06A0A9Teal-600 – border or indicator colour for selected.
—es-state-selected-text#044C51Teal-900 – text on selected-bg, AA compliant.
—es-state-disabled-bg#F6F3ECStone-100 – disabled control background.
—es-state-disabled-text#B9B7B0Stone-400 – disabled text. Below AA by design, to signal non-actionable.
—es-state-disabled-border#E7E5DDStone-200 – disabled border.
—es-state-disabled-opacity0.40Whole-element dim for disabled brand-coloured controls.
—es-state-readonly-bg#FCFAF3Stone-50 – read-only field background. Text stays at full contrast.
—es-state-loading-shimmerlinear-gradientSkeleton shimmer overlay, 1.6 s ease-in-out loop.