Interaction states
How hover, press, focus, disabled, selected and loading are derived in OKLCH from a single role, so every component reacts identically without a second colour.
Overview
An interaction state is the interface telling you what it is doing in response to you: that it noticed the pointer, that it accepted the press, that the keyboard is here, that this control is currently unavailable.
In most systems each state is a hand-picked colour, which means a hundred hand-picked colours, none of which were measured, several of which fail contrast, and all of which drift the moment somebody themes the product. In opsinjs a state is a derivation: a deterministic transformation in OKLCH applied to whichever role the component already uses. Pick the role once, and every state follows.
The thing this is most often confused with is data states. Interaction states describe what you are doing to a control. Data states describe what the system knows about the value inside it. A button can be hovered; it cannot be stale. A reading can be stale; it cannot be hovered into existence.
How it works
There are six, and every interactive component implements all six or its page says why one does not apply.
Hover is pointer only, and never the only signal for anything. Derived as a small lightness shift towards the surface's contrast direction: darker in the light theme, lighter in the dark theme, so the same rule works in both without a second token. Hover must never be the only way to discover an affordance, because touch has no hover and a good proportion of health app use is one-handed on a phone.
Press is the acknowledgement, and the state with the tightest timing budget.
It applies on pointer-down rather than on click, so a slow network never makes
the interface feel broken, and it uses the instant duration from
Motion. A press that animates in over 200 ms reads as lag.
Focus is always :focus-visible, never bare :focus, so a mouse click on a
button does not leave a ring behind it while a keyboard tab does. The ring is
drawn outside the control's box using an outline with an offset, so it never
changes layout and never gets clipped by an ancestor's overflow. Focus is the one
state whose appearance is not derived from the component's own role: it uses a
single system-wide focus role, so that the ring is equally visible on every
surface including the ones a person did not expect to be focusable.
Disabled is a reduction in contrast plus the removal of the pointer affordance. The hard rule is that a disabled control must still be readable: it is common to fade a disabled control to the point where nobody can tell what it says, which turns "you cannot do this yet" into "you cannot find out what this is". Disabled controls are also removed from the tab order, which means the reason they are disabled must be stated somewhere a keyboard user will reach.
Selected is persistent, chosen, and distinct from focus. Selection is carried by a fill and a mark, not by a fill alone, for the same reason status is: selection that is only a background colour disappears in greyscale and is announced by nothing.
Loading means the control is busy. The label is preserved rather than
replaced by a spinner, the control keeps its size so the layout does not jump,
and it becomes unavailable to activation without becoming disabled in the
accessibility sense, so its accessible name and its busy state can both be
announced.
The derivation itself lives in lib/color/derive.ts, and it is the same code the
theme generator uses. That is the point: a derived state cannot be out of step
with the role it derives from, and a preset that changes the role changes every
state with it.
What implements press today. Press is the one state a phone can show that hover cannot, so a system read one-handed by people over 60 has to carry it on every control a thumb reaches. It is implemented on Button and on the shared Link, and every component that composes them inherits it: Card, CareCard, MetricTile, Term, DisclaimerNote, ResultCard, AlertBanner and EmptyState all acknowledge a touch. Two controls are still short of it, the close control on Dialog and the close control on Sheet, which change their background on hover but give a touch no press cue.
There is no page-level press treatment, and that is deliberate. app/product.css
backstops focus with a global :focus-visible ring and backstops the touch floor
with a minimum block and inline size on interactive roles, and it restores the
browser tap highlight that the CSS reset had zeroed, but a global :active fill
would fight a control that presses by shifting rather than by filling. Each
control therefore declares its own press from the shared derivation, and the two
close controls above are the ones that have not yet.
Add the press acknowledgement to the close controls on Dialog and Sheet, or record on each page why a close control does not take it. They are the last two hand-rolled interactive elements in the registry that stop at hover and focus, and press is the state a one-handed reader on a phone can see when hover is not there to give.
Using it
Do not author a state colour. If you find yourself choosing a hover colour, the component is reaching past the semantic tier. See Token architecture. Choose the role; the state follows.
States compose, and the order is fixed. A control can be focused and hovered and pressed. The resolution order is disabled → loading → pressed → selected → focus → hover, and it is fixed so that a pressed control never loses its focus ring and a disabled control never appears hoverable.
Do
Disable the submit control while a reading is saving, keep its label, keep its width, and announce the busy state. The reader knows what is happening and where their action went.
Don’t
Replace the label with a spinner. The control changes width, the layout jumps under the reader's thumb, and anybody using a screen reader hears the accessible name disappear. Keep the label and set the busy state instead.
Health-specific: a state is never a verdict. A tapped result card may darken; it may not change status colour. Interaction feedback and clinical status draw from different places on purpose, and a component that tints its press state with a status colour has merged the two axes through the back door.
Tokens
Interaction states consume the colour roles and the two shortest duration steps;
they define no scale of their own. The derived values and the measured contrast
of every state against its own surface are generated by scripts/build-tokens.mts
and scripts/check-contrast.mts.
scripts/build-tokens.mts. Run pnpm run generate and reload.Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.
Accessibility impact
This foundation carries more conformance weight than any other page in the pillar except contrast, because four separate success criteria land on it.
- Focus visibility. The ring is measured against every surface it can appear on, in both themes, and a pair that falls below the published floor fails the build rather than shipping. Drawing outside the box with an offset is also what keeps the ring from being clipped, which is the most common way a technically present focus indicator becomes invisible in practice.
- Focus must not be lost. When a control becomes disabled or is removed, focus moves to a documented, predictable place. It never moves to the document body, which silently sends a keyboard user back to the top of the page.
- Hover is never load-bearing. Any information available only on hover is also available on focus and on tap, because touch devices have no hover and hover-only content is unreachable to a large group of readers.
- Disabled is a communication problem. Because disabled controls leave the tab order, the explanation must live outside them. A disabled control with no reachable reason is a dead end for anybody not using a mouse.
Measured focus-ring contrast for every surface pair is published on Contrast conformance; the keyboard contract itself is on Keyboard and focus.
Related
- Data states is the other half of "what state is this in", and the one that matters more on a health screen.
- Motion → Using motion says which duration and easing each of these six states is allowed to use, and why press is the tightest.
- Handbook → Data attributes has the
data-*vocabulary these states are exposed through, so you can style them yourself.
Layering and elevation
The fixed stacking order every overlay, sheet, popover and toast belongs to, and the health rule that nothing may ever cover the value it is talking about.
Data states
Empty, loading, error, stale and partial are the five states every data-bearing surface must render, so "we do not know" is never drawn as an ordinary reading.