---
title: 'Interaction States'
source: 'https://academia.sh/en/courses/interface-fundamentals/interaction-states'
course: 'Fundamentals of Interface Design'
language: en
updated: '2026-08-17T18:11:56+00:00'
license: 'CC BY-SA 4.0'
---

# Interaction States

Definition of the five interaction states, tying state change to a measurable threshold, the channel conflict between hover and selected, and the area and contrast calculation of the focus indicator.

The previous topic completed the static appearance of the catalog interface: the text
takes a step from the scale, a value from the color role table, and both themes have
been checked. But the interface is not static. The Borrow button looks different when
the pointer hovers over it, when it receives keyboard focus, when it is held down, and
when it is unavailable because the record is not on the shelf.

Each of these appearances is a separate design decision, and none of them has been
defined yet. Left undefined, the decisions get made one at a time as each component is
coded; two buttons in the same interface end up responding differently, and the user
never learns what any given response means.

## Five States

An interactive component carries the following states:

- **Default.** The appearance of the component under no interaction at all. Every
  other state is defined relative to it.
- **Hover.** The state in which the pointer sits over the component. The information
  it communicates is: this is a clickable spot. It exists only with pointer input.
- **Focus.** The state in which the component is the element that will receive
  keyboard input. The information it communicates is: this is what a keypress will
  affect.
- **Active.** The state in which the component is currently being pressed. The
  information it communicates is that input has been received.
- **Disabled.** The state in which the component cannot currently be used.

Two of the five states are frequently confused. Hover and focus come from different
input types and communicate different things; using one in place of the other means a
keyboard user navigating the interface cannot see where they are. As shown in the
Screen Size and Input Type lesson, hover never occurs with touch input; focus occurs
with every input type. For this reason, hover can only carry **supplementary**
information, never load-bearing information.

States can combine. A button can be both focused and active at the same time; it can
be both focused and hovered. States are therefore not a list of mutually exclusive
options but layers that stack on top of one another, and each one has to use a
**separate channel**.

## State Change Must Be Measurable

A state that cannot be distinguished from the default does not exist. The measure of
the distinction is the contrast ratio used in earlier lessons; the threshold is the
value of 1.2 this course has adopted.

```js
// states.mjs — separability of interaction states from the default and text contrast

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
const toHex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const TONE = { neutral: [214, 8], primary: [214, 62] };
const color = (family, b) => hslRgb(TONE[family][0], TONE[family][1], LIGHTNESS[b]);

const SEPARATION_THRESHOLD = 1.2;   // the smallest state change ratio this course adopts
const TEXT_THRESHOLD = 4.5;

const FILLED_BUTTON = [
  { state: "default",  surface: ["primary", "600"], text: ["neutral", "000"] },
  { state: "hover",    surface: ["primary", "700"], text: ["neutral", "000"] },
  { state: "active",   surface: ["primary", "800"], text: ["neutral", "000"] },
  { state: "disabled", surface: ["neutral", "200"], text: ["neutral", "400"] },
];

const OUTLINE_BUTTON = [
  { state: "default",  surface: ["neutral", "000"], text: ["primary", "600"] },
  { state: "hover",    surface: ["primary", "100"], text: ["primary", "700"] },
  { state: "active",   surface: ["primary", "200"], text: ["primary", "800"] },
  { state: "disabled", surface: ["neutral", "000"], text: ["neutral", "400"] },
];

function report(title, list) {
  console.log(`\n--- ${title} ---`);
  console.log("state           surface  text     text/surface  surface diff  text diff     carrying channel");
  const t = list[0];
  for (const d of list) {
    const y = color(...d.surface);
    const m = color(...d.text);
    const km = contrast(m, y);
    const ay = contrast(y, color(...t.surface));
    const am = contrast(m, color(...t.text));
    const channels = [];
    if (ay >= SEPARATION_THRESHOLD) channels.push("surface");
    if (am >= SEPARATION_THRESHOLD) channels.push("text");
    console.log(
      `${d.state.padEnd(15)} ${toHex(y)}  ${toHex(m)}  ${km.toFixed(2).padStart(9)}:1  ` +
        `${ay.toFixed(3).padStart(12)}  ${am.toFixed(3).padStart(12)}  ` +
        (d === t ? "-" : channels.length ? channels.join("+") : "NOT SEPARATED") +
        (km < TEXT_THRESHOLD ? "  [below text threshold]" : "")
    );
  }
}

report("primary button (filled)", FILLED_BUTTON);
report("secondary button (unfilled)", OUTLINE_BUTTON);

// Result list row: hover and selected share the same channel
console.log("\n--- result list row ---");
console.log("state           surface  vs default         vs hover");
const ROW = [
  ["default", ["neutral", "000"]],
  ["hover", ["neutral", "050"]],
  ["selected", ["primary", "100"]],
];
for (const [name, base] of ROW) {
  const y = color(...base);
  console.log(
    `${name.padEnd(15)} ${toHex(y)}  ${contrast(y, color("neutral", "000")).toFixed(3).padStart(17)}  ${contrast(y, color("neutral", "050")).toFixed(3).padStart(21)}`
  );
}

// For the row, at which step does the hover surface clear the threshold?
console.log("\nhover candidate      vs default         vs selected");
for (const b of ["050", "100", "200"]) {
  const y = color("neutral", b);
  console.log(
    `${("neutral-" + b).padEnd(20)} ${contrast(y, color("neutral", "000")).toFixed(3).padStart(17)}  ${contrast(y, color("primary", "100")).toFixed(3).padStart(20)}`
  );
}
```

```
--- primary button (filled) ---
state           surface  text     text/surface  surface diff  text diff     carrying channel
default         #275ea5  #ffffff       6.50:1         1.000         1.000  -
hover           #1e4980  #ffffff       9.05:1         1.393         1.000  surface
active          #15335b  #ffffff      12.68:1         1.950         1.000  surface
disabled        #d3d6d9  #969da6       1.88:1         4.454         2.738  surface+text  [below text threshold]

--- secondary button (unfilled) ---
state           surface  text     text/surface  surface diff  text diff     carrying channel
default         #ffffff  #275ea5       6.50:1         1.000         1.000  -
hover           #dee9f7  #1e4980       7.37:1         1.228         1.393  surface+text
active          #bdd3ef  #15335b       8.29:1         1.530         1.950  surface+text
disabled        #ffffff  #969da6       2.74:1         1.000         2.374  text  [below text threshold]

--- result list row ---
state           surface  vs default         vs hover
default         #ffffff              1.000                  1.071
hover           #f7f7f8              1.071                  1.000
selected        #dee9f7              1.228                  1.147

hover candidate      vs default         vs selected
neutral-050                      1.071                 1.147
neutral-100                      1.204                 1.020
neutral-200                      1.459                 1.189
```

In the filled button, both the hover and active states clear the threshold (1.393 and
1.950), and both sit on a single channel: the surface. This is acceptable, because both
are transient states and carry no information.

Text contrast is preserved in every state, and even rises: 6.50, 9.05, 12.68. This is
the result of the direction chosen; as the fill darkens, the contrast of the white text
increases. Had the opposite direction been chosen, the active state could have missed
the text threshold.

In the outline button, both states separate on two channels at once. The reason is
that the surface separation stays weak in the unfilled button: 1.228, just above the
threshold. The text channel accompanying it compensates for this weakness.

The disabled state stays below the text threshold in both buttons. As seen in the
Contrast and Accessibility lesson, inactive components fall outside the scope of 1.4.3,
so this is not a violation. But the decision made there applies here too: if the user
needs to read what the button does, the button is not disabled.

## Hover and Selected Cannot Share the Same Channel

The third table shows two separate problems at once in the result list row.

First, the hover surface sits at neutral 050 and produces only 1.071 contrast against
the default. The threshold is 1.2, so this state does not produce a measurable change.

Second, and more serious, the selected state carries only 1.147 contrast against the
hover state. The two states do not separate from each other: the user cannot tell
whether a row is selected or under the pointer.

The fourth table shows the problem cannot be solved within the surface channel.
Neutral 100 clears the threshold against the default (1.204) but drops to 1.020
against the selected state — the two states merge completely. Neutral 200 clears the
default comfortably (1.459) but stays below the threshold against the selected state
at 1.189. None of the three candidates satisfies both conditions.

The reason is structural: both states are coded on the same axis, surface lightness,
and the two values must separate from the default on one side and from each other on
the other. The axis cannot carry that much load.

The solution is to distribute the two states across separate channels. Being selected
is a persistent, information-carrying state; it is marked with a vertical bar placed at
the start of the row that clears the 3.0 threshold against the surface. Hover is
transient; it stays on the surface channel and clears the threshold by moving to the
neutral 100 step. Because the two states sit on different axes, they remain
distinguishable even when they occur at the same time. The selected-row finding in the
Contrast and Accessibility lesson called for this same second channel.

## The Focus Indicator Cannot Be Removed

The focus state differs from the other four because it alone is bound to a conformance
requirement. Success Criterion 2.4.7 Focus Visible requires every keyboard-operable
element to carry a visible focus indicator. The indicator cannot be removed; it can
only be changed.

What the indicator looks like is bound to two measures. The contrast measure comes
from 1.4.11: the indicator must separate from its neighboring colors by at least 3:1.
The area measure comes from Success Criterion 2.4.13 Focus Appearance: the area the
indicator covers must equal the area of a 2-pixel-thick perimeter around the component.

```js
// focus.mjs — checking the focus indicator against the area and contrast criteria

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
const toHex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

// Area criterion: the indicator area must equal the component's 2 px perimeter.
const required = (w, h) => (w + 4) * (h + 4) - w * h;

// Ring area: thickness k, offset o
function ringArea(w, h, k, o) {
  const outW = w + 2 * (o + k), outH = h + 2 * (o + k);
  const inW = w + 2 * o, inH = h + 2 * o;
  return outW * outH - inW * inH;
}

const COMPONENTS = [
  { name: "borrow button", w: 132, h: 44 },
  { name: "icon button", w: 24, h: 24 },
];
const CANDIDATES = [
  { name: "1px, no gap", k: 1, o: 0 },
  { name: "2px, no gap", k: 2, o: 0 },
  { name: "2px, 2px gap", k: 2, o: 2 },
  { name: "3px, 1px gap", k: 3, o: 1 },
];

for (const b of COMPONENTS) {
  const g = required(b.w, b.h);
  console.log(`\n--- ${b.name} (${b.w}x${b.h} px) --- required minimum area: ${g} px2`);
  console.log("indicator          area (px2)  ratio to required  result");
  for (const a of CANDIDATES) {
    const area = ringArea(b.w, b.h, a.k, a.o);
    console.log(
      `${a.name.padEnd(17)} ${String(area).padStart(10)} ${(area / g).toFixed(3).padStart(20)}  ${area >= g ? "passed" : "FAILED"}`
    );
  }
}

// Contrast criterion: the ring must separate from both the page surface and the component fill.
const LIGHTNESS = { "000": 100, 100: 92, 500: 50, 600: 40, 700: 31, 900: 14 };
const TONE = { neutral: [214, 8], primary: [214, 62] };
const color = (family, b) => hslRgb(TONE[family][0], TONE[family][1], LIGHTNESS[b]);

const SURFACE = color("neutral", "000");
const FILL = color("primary", "600");
console.log("\nring color    vs surface    vs fill    both >=3.0");
for (const [family, base] of [["primary", "500"], ["primary", "700"], ["neutral", "900"], ["neutral", "500"]]) {
  const h = color(family, base);
  const ky = contrast(h, SURFACE);
  const kd = contrast(h, FILL);
  console.log(
    `${(family + "-" + base).padEnd(13)} ${ky.toFixed(2).padStart(12)}  ${kd.toFixed(2).padStart(13)}  ${ky >= 3 && kd >= 3 ? "passed" : "FAILED"}`
  );
}

// Two-layer ring: inner layer in the surface color, outer layer dark.
console.log("\ntwo-layer ring (inner: surface, outer: neutral-900)");
const inner = SURFACE, outer = color("neutral", "900");
console.log(`inner layer / fill   : ${contrast(inner, FILL).toFixed(2)}:1`);
console.log(`outer layer / surface: ${contrast(outer, SURFACE).toFixed(2)}:1`);
console.log(`outer layer / inner  : ${contrast(outer, inner).toFixed(2)}:1`);
console.log(`area (132x44, inner 2px + outer 2px): ${ringArea(132, 44, 4, 0)} px2, required ${required(132, 44)} px2`);
```

```
--- borrow button (132x44 px) --- required minimum area: 720 px2
indicator          area (px2)  ratio to required  result
1px, no gap              356                0.494  FAILED
2px, no gap              720                1.000  passed
2px, 2px gap             752                1.044  passed
3px, 1px gap            1116                1.550  passed

--- icon button (24x24 px) --- required minimum area: 208 px2
indicator          area (px2)  ratio to required  result
1px, no gap              100                0.481  FAILED
2px, no gap              208                1.000  passed
2px, 2px gap             240                1.154  passed
3px, 1px gap             348                1.673  passed

ring color    vs surface    vs fill    both >=3.0
primary-500           4.59           1.41  FAILED
primary-700           9.05           1.39  FAILED
neutral-900          15.74           2.42  FAILED
neutral-500           4.11           1.58  FAILED

two-layer ring (inner: surface, outer: neutral-900)
inner layer / fill   : 6.50:1
outer layer / surface: 15.74:1
outer layer / inner  : 15.74:1
area (132x44, inner 2px + outer 2px): 1472 px2, required 720 px2
```

The area table gives a one-line result: a one-pixel ring covers about half the
criterion. The ratio is roughly the same for both components — 0.494 and 0.481 —
because the criterion is already proportional to the component's perimeter. A
two-pixel ring meets the criterion exactly; adding a gap makes the area slightly
larger.

The contrast table gives a harsher result. **None** of the four ring-color candidates
satisfy both conditions. The reason is that the ring has two different neighbors: the
light page surface outside, the dark button fill inside. Separating from the surface
requires being dark; separating from the fill requires being light — a single color
cannot do both.

The final block gives the solution. The ring is drawn in two layers: the inner layer in
the page surface color, the outer layer dark. The inner layer carries 6.50 contrast
against the button fill, the outer layer 15.74 against the page surface; both
conditions are satisfied. At a total thickness of 4 pixels, the area is 1472 square
pixels, more than twice the 720 required.

This solution has a side effect: whatever the background, one of the two layers is
always guaranteed to separate from its neighbor. This property is what makes it
possible to write the focus indicator as a single rule instead of adjusting it
component by component.

## The State Table Is the Component's Contract

A component's design is completed not by a single appearance but by its state table.
Each row of the table is a state, each column a channel; a cell left blank says that
state does not change on that channel.

A complete table means a complete design. The most frequently skipped rows are focus
and disabled; both require an interaction, so neither is visible on screen when the
design is reviewed. Writing the table down prevents this omission.

A second conclusion follows from the table: states are defined for a **role, not a
component**. The same state definition applies to the search button, the apply-filter
button, and the extend-due-date button; once it is defined at the role level, the
decision does not need to be made again for a new button.

## Summary

- Interactive components carry five states, and each communicates different
  information; hover exists only with pointer input, so it can never carry
  load-bearing information.
- State change must be measurable; the distinction from the default is tied to a
  threshold, and the channel it occurs on is written down.
- State transitions built by darkening the fill raise text contrast; choosing the
  opposite direction can miss the text threshold.
- Hover and selected cannot be coded on the same axis; because surface lightness
  cannot carry two separations at once, the persistent state moves to a second
  channel.
- The focus indicator cannot be removed; the area criterion requires an area equal to
  the component's 2-pixel perimeter, and the contrast criterion requires 3:1
  separation from its neighbors.
- A single-color ring cannot satisfy two different neighbors at once; a two-layer ring
  works against any background and can be written as a single rule.

## Next Step

All five states assumed the component was **ready**. But the catalog interface may be
waiting for data to arrive, a search may have returned no results, or the user may not
have searched for anything yet. In these three cases there is no component to show on
screen, and what needs designing is waiting and emptiness themselves. The next lesson
covers which duration thresholds change the loading indicator, when a skeleton view
becomes misleading, and why an empty state is not an error message.
