Interaction states
Communicate what a control can do, what is happening, and what happened. Visual styling, browser behavior and accessible state must tell the same story.
State specifications
| State | Meaning | Required behavior |
|---|---|---|
| Enabled | Available for input or activation. | Use the ordinary component appearance and descriptive label. |
| Hovered | A pointer rests over the control. | Add emphasis; never put required information only on hover. |
| Focused | The control receives keyboard input. | Use :focus-visible with a visible ring; focus is not selection. |
| Pressed | A pointer or key is actively held. | Use the active token; complete the action on the expected native event. |
| Dragged | An item is being moved. | Distinguish the moving item, preserve identity, and provide cancellation and non-drag controls. |
| Selected | A choice or current destination persists. | Use checked/selected/current semantics appropriate to the control. |
| Disabled | A control is unavailable. | Prevent activation and explain prerequisites nearby. |
| Loading | An action is in progress. | Preserve its label and layout; announce progress and prevent duplicates. |
| Error | Input or an action needs recovery. | State the problem and a concrete next step beside the affected input. |
| Success | The requested operation completed. | Confirm the actual outcome with text; do not announce success before persistence. |
States can coexist: a selected tab may also be focused or hovered. Error is not a reason to remove focus styling. Disabled controls must never show a pressed response that implies activation.
State explorer
Try an empty search, a valid search, a pending request, success, error and cancellation. The simulated request stays pending until you choose its outcome; no network request or language generation occurs.
Enter any English search term for this local demonstration.
Container, state layer and content
Material’s reference places one translucent state overlay between the container and its content. Its color follows the content role: a secondary-container surface uses on-secondary-container; a surface with primary-colored content uses primary. Keep the content opaque and independently legible. Whole-control and circular layers are presentation choices, separate from the interactive target.
The captured reference specifies hover 8%, focus 10%, press 10% and drag 16% overlay opacity, with a 40dp layer illustration inside a 48dp target. These are reference values, not this library’s state tokens or a conversion into CSS pixels. A 10% overlay does not independently prove foreground or boundary contrast. Check the full transition as well as its endpoints; interpolating a foreground and background separately can temporarily erase the state indicator.
MobTranslate’s primary button instead changes its background to --color-primary-hover or --color-primary-active; focus has an independent ring. Its disabled/data-disabled selectors suppress hover and active styling. Review the actual component stylesheet and color pairs rather than applying a second Material overlay over an existing recipe.
Transient press and ripple presentation
Material commonly illustrates a press with a ripple overlay, sometimes alongside an elevation change. The current web button uses native active color and shadow behavior; it does not install a ripple generator. A custom ripple should be decorative, leave the target and focus indicator intact, avoid intercepting pointer events, and never delay the action. Keep an immediate reduced-motion state response. A ripple is transient feedback, not persistent aria-pressed selection.
Combined states and visual precedence
Persistent selection can coexist with hover, focus or active input. Material depicts one transient state layer at a time; combining translucent overlays can produce an unintended fill. Define the family’s precedence explicitly, retain selection semantics, and preserve the focus indicator throughout. Do not remove error text when a field gains focus.
Browser :hover can match nested ancestors, multiple pointers can exist, and focus may be delegated inside a composite widget. Inspect the actionable part rather than asserting a universal one-hover-element rule. aria-pressed is persistent toggle state; :active is transient activation styling. Neither proves that a remote action completed.
Apply states to actionable parts
A container does not acquire every state of its children. An app bar’s buttons can hover/focus/press while the bar stays structurally stable; a badge communicates information rather than becoming an action. A draggable row can use a boundary or lift without making its move buttons draggable controls. Match the state to the actual interaction owner.
Material distinguishes which families inherit disabled, hover, focused, pressed and dragged treatments, and advises hiding an unavailable FAB. This React library may expose disabled members in a family with a different native reference policy. Follow the installed API and task requirements; do not globally hide tabs, navigation or recovery controls because of that reference rule.
Native disabled controls prevent activation and normally leave sequential focus. aria-disabled preserves discoverability only when the implementation also blocks the action. Inactive-control contrast exceptions do not justify low-contrast prerequisite explanations or status text. Focus, disabled, read-only and pending remain separate contracts.
State anatomy and tokens
Separate the label, visual surface, focus indicator and feedback message. Hover and active colors come from semantic tokens such as --color-primary-hover and --color-primary-active. A loading spinner supports the label; it does not replace it. Error messages describe the recovery and link to the input through aria-describedby.
const [pending, setPending] = useState(false);
const [message, setMessage] = useState('');
async function save() {
if (pending) return;
setPending(true);
setMessage('Saving changes…');
try {
await persistChanges(); // implement your real persistence boundary
setMessage('Changes saved.');
} catch {
setMessage('Changes were not saved. Check your connection and retry.');
} finally {
setPending(false);
}
}
<Button loading={pending} onClick={save}>Save changes</Button>
<p role="status">{message}</p>Use the library’s real props rather than styling a disabled-looking div. Native disabled removes a button from Tab order. If a control must remain discoverable, aria-disabled can be appropriate, but the application must also prevent activation and explain why.
Input, focus and gestures
- Buttons activate with Enter or Space; links navigate with Enter. Use native elements so pointer and keyboard follow those conventions.
- Focus identifies where the next key goes. Selection identifies a persistent choice. Tabs therefore need both their selected state and an independently visible focus indicator.
- Touch has no dependable hover. Put essential labels, explanations and actions in visible content.
- Provide a button alternative for drag, swipe or long-press operations. Never make a gesture the only way to perform an essential task.
- When dismissing an overlay, restore focus to its trigger unless the workflow deliberately moved to a new destination.
Pending, error and recovery
Preserve user input through request failures. Disable the submitting action while pending to prevent duplicate writes; keep cancellation or another safe escape available for long work. Show progress only when it is measured. For unknown duration, use indeterminate feedback with a plain-language status.
Validate a field after an appropriate user interaction or submission, not before someone has had a chance to answer. Keep the field label and error message visible together. After a failed submission, move focus to an error summary or the first invalid field when it helps locate the problem. Do not reset the form or move focus for every keystroke.
The local explorer marks only an empty term invalid. A simulated request failure retains a valid term and offers retry; cancellation also retains it. Resolving the simulated pending controls restores focus to the surviving field rather than leaving it on an unmounted result button.
Announcements and accessible state
- Use
role="status"for ordinary progress and completion. Reserve assertive alerts for urgent failures needing immediate attention. - Use
aria-invalidfor invalid fields,aria-expandedfor disclosure triggers andaria-current="page"for navigation. - Use
aria-pressedonly for toggle buttons. A Save button is an action, not a persistent pressed choice. - Keep status truthful: a citation, an internal review flag and community endorsement are distinct. A green success treatment must not imply authority that has not been recorded.
- Respect reduced motion and forced colors. A border or text label must still show state if animation and colors disappear.
Do and avoid
| Do | Avoid |
|---|---|
| Keep text labels alongside error or success icons. | Red or green alone as the only signal. |
| Explain why a prerequisite blocks an action. | An unexplained grey button that people cannot activate. |
| Confirm a completed write after the server succeeds. | Success feedback on click before persistence is known. |
| Keep pending controls stable and provide recovery. | A spinner that hides all content and never offers cancellation. |
Compiled state explorer
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { Button, Input } from '@mobtranslate/ui';
import './foundation-labs.css';
export function LayoutLab() {
const id = useId();
const [width, setWidth] = useState(720);
const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr');
const compact = width < 640;
return (
<div className="space-y-4">
<label htmlFor={id} className="block font-medium">Example container width: {width}px</label>
<input id={id} type="range" min="280" max="960" step="20" value={width} onChange={(event) => setWidth(Number(event.target.value))} className="w-full min-h-11" />
<label className="flex items-center gap-3 min-h-11"><input type="checkbox" checked={direction === 'rtl'} onChange={(event) => setDirection(event.target.checked ? 'rtl' : 'ltr')} />Right-to-left layout</label>
<div role="region" className="overflow-x-auto border border-[var(--color-border)] rounded-lg p-3" tabIndex={0} aria-label="Scrollable responsive layout example">
<div dir={direction} style={{ width, display: 'grid', gridTemplateColumns: compact ? '1fr' : '160px minmax(0, 1fr)', gap: '1rem' }}>
<div className="bg-[var(--color-muted)] rounded p-4">{compact ? 'Menu in header' : 'Navigation column'}</div>
<div className="border border-[var(--color-border)] rounded p-4 space-y-3">
<h3 className="font-semibold">Dictionary search</h3>
<p className="text-sm">Search and source details stay together. In compact layouts the navigation becomes a menu; the reading column keeps its full width.</p>
<div style={{ display: 'grid', gridTemplateColumns: compact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: '0.75rem' }}>
<div className="p-3 bg-[var(--color-muted)] rounded">Entry details</div>
<div className="p-3 bg-[var(--color-muted)] rounded">Source and review state</div>
</div>
</div>
</div>
</div>
<p role="status" className="text-sm">{compact ? 'Compact: one column with header navigation.' : 'Expanded: navigation and content in separate columns.'} This is an intentionally scrollable container demonstration; the page itself reflows.</p>
</div>
);
}
export { ShapeLab } from './shape-explorer';
export function StateLab() {
const id = useId();
const [value, setValue] = useState('');
const [state, setState] = useState<'ready' | 'loading' | 'validation' | 'error' | 'success' | 'cancelled'>('ready');
const [disabled, setDisabled] = useState(false);
const input=useRef<HTMLInputElement>(null);
const restoreFocus=useRef(false);
useEffect(()=>{if(restoreFocus.current&&state!=='loading'){input.current?.focus();restoreFocus.current=false;}},[state]);
const finish=(outcome:'success'|'error'|'cancelled')=>{restoreFocus.current=true;setState(outcome);};
const message = state === 'validation' ? 'Enter a search term before continuing.' : state === 'error' ? 'Example request failed. Your term is retained; retry or reset this local example.' : state === 'cancelled' ? 'Example request cancelled. Your term is retained.' : state === 'loading' ? 'Example request is waiting. Choose a result below.' : state === 'success' ? 'Example request completed. No dictionary data was sent.' : 'Enter any English search term for this local demonstration.';
return (
<div className="space-y-4 max-w-md" data-state-lab>
<label htmlFor={id} className="block font-medium">Search term</label>
<Input ref={input} id={id} value={value} onChange={(event) => { setValue(event.target.value); setState('ready'); }} disabled={disabled || state === 'loading'} aria-invalid={state === 'validation'} aria-describedby={`${id}-status`} />
<p id={`${id}-status`} role="status" className="text-sm">{message}</p>
<Button data-state-submit disabled={disabled} loading={state === 'loading'} onClick={() => setState(value.trim() ? 'loading' : 'validation')}>Search example</Button>
{state === 'loading' && <div className="flex flex-wrap gap-2">
<Button data-state-outcome="success" variant="outline" onClick={() => finish('success')}>Simulate success</Button>
<Button data-state-outcome="error" variant="outline" onClick={() => finish('error')}>Simulate error</Button>
<Button data-state-outcome="cancelled" variant="ghost" onClick={() => finish('cancelled')}>Cancel request</Button>
</div>}
<label className="flex gap-3 items-center min-h-11"><input data-state-disable type="checkbox" checked={disabled} onChange={(event) => { setDisabled(event.target.checked); setState('ready'); }} />Disable the example controls</label>
<Button data-state-reset variant="ghost" onClick={() => { setState('ready'); setValue(''); setDisabled(false); }}>Reset example</Button>
</div>
);
}
[data-state-lab] { min-inline-size:0; }
[data-state-lab] .mt-btn { min-inline-size:0; max-inline-size:100%; min-block-size:44px; block-size:auto; padding-block:.5rem; padding-inline:.5rem; white-space:normal; overflow-wrap:anywhere; }
[data-state-lab] p,[data-state-lab] label { overflow-wrap:anywhere; }
This checked source also contains the layout lab and shape export. The state example’s outcome controls are explicit local simulation; persistence belongs to the consuming application.
Verification and related guidance
Exercise each applicable state with pointer, touch and keyboard. Check combined states, repeated activation, network failure, cancellation and unmount during pending work. Inspect accessible names and state attributes, then check actual announcements with a screen reader.