---
title: "Interaction states"
description: "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."
url: "https://opsinjs.pensievelabs.org/foundations/interaction-states"
source: "https://opsinjs.pensievelabs.org/foundations/interaction-states.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["hover", "pressed", "focus-visible", "disabled state", "selected state"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#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](./data-states.mdx).
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 [#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](./motion/index.mdx). 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.

<Todo>
  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.
</Todo>

## Using it [#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](./token-architecture.mdx). 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.

<DoDont>
  <DoDont.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.
  </DoDont.Do>

  <DoDont.Dont>
    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.
  </DoDont.Dont>
</DoDont>

**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](../health/two-colour-axes.mdx) 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 [#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`.

<NoDataYet script="scripts/build-tokens.mts" />

## Accessibility impact [#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](../accessibility/contrast-conformance.mdx); the keyboard
contract itself is on
[Keyboard and focus](../accessibility/keyboard-and-focus.mdx).

## Related [#related]

* [Data states](./data-states.mdx) is the other half of "what state is this in",
  and the one that matters more on a health screen.
* [Motion → Using motion](./motion/using-motion.mdx) says which duration and easing
  each of these six states is allowed to use, and why press is the tightest.
* [Handbook → Data attributes](../handbook/data-attributes.mdx) has the `data-*`
  vocabulary these states are exposed through, so you can style them yourself.
