Interaction states
Defines how controls show hover, press, focus, selection, and pending work.
Principles
- Give each state its own signal. Hover, press, focus, selection, and pending mean different things, so never merge them into one color.
- Never hide an action behind hover or drag. Touch and keyboard users have neither, so give every action a visible control.
- Leave focus where the reader put it. Show it with the shared ring, and don't move it when a task finishes.
- Treat pending as not done. Block a second start, offer cancel when you can, and announce only the real result.
- Explain an unavailable action. Put the reason beside a disabled control, and use read-only when people still need the value.
State model
A state tells the reader what they can do now and what happened. Shared controls draw each one with its own token.
Hover and pressed paint as a layer over whatever fill the control already has, so they read the same on a plain button, a pressed toggle and a selected row. A selected fill sets only the color under that layer.
In forced colors the system palette replaces every token. Hover and pressed layers drop away and focus keeps its ring. A selected, current or pressed control fills with Highlight and writes its label in HighlightText. A highlighted option in a popup list, and a selected row that holds its own links, draw a CanvasText outline instead; a popup shows what is chosen with its check mark.
Focus ring
Buttons and choices draw a --surf-focus-ring-width ring at --surf-focus-ring-offset, both 2px, in --surf-interaction-focus. They show it for keyboard focus only (:focus-visible).
Text fields turn their border --surf-control-focus and add a 3px --surf-control-focus-ring halo on any focus. Where a container clips, the ring moves inside by --surf-focus-ring-inset.
Selection and activation
Mouse, touch, Enter, and Space run the same action. Below, the toggle keeps a selection, the disabled button can't run, and the read-only field stays copyable.
Evidence excluded.
Focus and copy this value. It can't be edited.
Link a disabled control to its reason with aria-describedby. Keep each component's native keys, and don't add a keydown handler that runs the action twice.
Pending work
While pending, the run button keeps focus and blocks a second start. Cancel returns focus to it, and finishing moves nothing. Try failure, cancel, and retry on this 900ms local task.
Started attempts: 0
No preview has run.
Respond to a press within 100ms. Show a spinner for work that can pass 1 second, and progress for work past 10 seconds.
A disabled state alone stops no request, so guard the handler too. For real writes, the server owns idempotency and permission.