Switch
Name a binary feature setting consistently and distinguish requested changes from retained outcomes.
A binary setting
Use Switch for an on/off feature setting. Use Checkbox for inclusion in a form and Toggle for a pressed action such as emphasis. Keep the feature name stable; the checked state communicates on/off.
A real remote setting may need pending, failure and recovery. The example preserves the previous setting until a controlled local outcome and prevents a duplicate change while pending. It schedules no reminders.
Anatomy and specifications
| Part | Current specification |
|---|---|
| Control / ref | Default span switch plus hidden input; public HTMLElement ref |
| Track | 44×24px, fully rounded, 2px readable boundary |
| Thumb | 20px; logical inline positioning; retained checked state changes position/fill |
| Label target | 44px minimum height and width, stable --text-base text |
| Pending | Consumer-owned readOnly guard and persistent explanation; not an automatic request |
| Motion | Logical-position transition; removed under reduced motion |
Immediate settings and reference handle behavior
A switch should request a change immediately without a separate Save step. Show local example details below demonstrates an actual immediately effective display setting. The reminders specimen separately exercises an asynchronous outcome boundary: changing the switch starts a request immediately, keeps the retained setting until completion and exposes failure/retry. Its explicit Apply outcome control simulates completion for the guide; it is not a production Save requirement or an actual reminder scheduler.
Use a connected single-choice group for opposing views such as list/map, checkboxes for related saved selections and a button for a call to action. Keep a concise adjacent label describing the feature when on; do not squeeze label text inside the track or substitute ambiguous moon/edit imagery for a binary state. An optional reference handle icon represents on/off, not the feature name.
| Reference point | Local implementation |
|---|---|
| Measurements | Native reference track 52×32dp, 2dp outline and 48dp target; handle 16dp off, 24dp on/with icon, 28dp pressed, icon 16dp and 40dp state layer. Local track is nominally 44×24 CSS px with a 20px stable thumb at a 16px root and a minimum 44px label target. |
| Motion and input | Reference handle size can grow on hover/press/drag. This package moves a fixed-size thumb logically and supports installed pointer/keyboard activation; it does not expose a handle-icon/size-morph or drag-to-toggle contract. Reduced motion removes the positional transition. |
| Focus and naming | The reference describes focus on the handle; the local focusable root exposes switch semantics and a visible ring around the control. Tab reaches that control; Space toggles the mutable setting. Any Enter support must be verified against the installed primitive. |
| Density and labels | Material recommends 48×48 CSS px targets and reversible opt-in density. The local 44px minimum is an explicit site choice, with a foreground label that stays stable across off/on/pending states. |
Working requested, retained and failed settings
Local reminders are off.
The stable label names the feature. The checked state describes the retained setting, not the requested change.
'use client';
import { useRef, useState } from 'react';
import { Button, RadioGroup, RadioItem, Switch } from '@mobtranslate/ui';
export function RadioSelectionExample() {
const [value, setValue] = useState('compact');
const [readOnly, setReadOnly] = useState(false);
const [result, setResult] = useState('No local radio form submitted.');
const [required,setRequired]=useState(false);
const [invalid,setInvalid]=useState(false);
const first=useRef<HTMLElement>(null);
const effective=value||'compact';
return <div data-radio-lab className="space-y-4">
<Button type="button" variant="outline" aria-pressed={readOnly} onClick={() => setReadOnly(current => !current)}>Make local radio group read-only</Button>
<div className="flex flex-wrap gap-3"><Button type="button" variant="outline" aria-pressed={required} onClick={()=>{setRequired(current=>!current);setValue('');setInvalid(false);setResult('No new local radio form submitted.');}}>Require an explicit radio choice</Button><Button data-radio-restore type="button" variant="outline" onClick={()=>{setRequired(false);setValue('compact');setReadOnly(false);setInvalid(false);}}>Restore initial radio sample</Button></div>
<form noValidate className="space-y-3" onSubmit={event => { event.preventDefault(); if(required&&!value){setInvalid(true);first.current?.focus();return;}setInvalid(false);setResult(`Local FormData density=${new FormData(event.currentTarget).get('density')}.`); }}>
<fieldset><legend className="font-semibold">Local reading density</legend>
<RadioGroup name="density" value={value} readOnly={readOnly} required={required} aria-invalid={invalid||undefined} aria-describedby={invalid?'radio-required-error':undefined} onValueChange={next => { if (typeof next === 'string') {setValue(next);setInvalid(false);} }} aria-label="Local reading density">
<RadioItem ref={first} value="compact" label="Compact reading" /><RadioItem value="comfortable" label="Comfortable reading" /><RadioItem value="unavailable" label="Unavailable reading option" disabled />
</RadioGroup>
{invalid&&<p id="radio-required-error" role="alert">Choose a reading density before submitting.</p>}
</fieldset>
<Button type="submit">Submit radio sample</Button>
</form>
<p dir="auto" data-density-sample style={{lineHeight:effective === 'compact' ? 1.5 : 1.75, paddingBlock:effective === 'compact' ? 8 : 16}}>This English-only reading sample changes its local spacing without changing the text or any source record.</p>
<p role="status" dir="auto">Selected local density: {value||'(none)'}. {value?'':'The preview retains compact baseline spacing until a choice is made. '}{result}</p>
</div>;
}
export function SwitchSelectionExample() {
const [checked, setChecked] = useState(false);
const [details,setDetails]=useState(false);
const [pending, setPending] = useState(false);
const [requested, setRequested] = useState(false);
const [result, setResult] = useState('Local reminders are off.');
const control = useRef<HTMLElement>(null);
return <div data-switch-lab className="space-y-4">
<Switch ref={control} label="Local example reminders" checked={checked} readOnly={pending} aria-describedby="local-switch-state" onCheckedChange={next => { if (!pending) { setRequested(next); setPending(true); setResult('Local change pending; current setting is retained until an outcome.'); } }} />
<p id="local-switch-state" role="status">{result}</p>
{pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={() => { setChecked(requested); setPending(false); control.current?.focus(); setResult(`Local reminders are ${requested ? 'on' : 'off'}. No reminder was scheduled.`); }}>Apply local switch change</Button><Button type="button" variant="secondary" onClick={() => { setPending(false); control.current?.focus(); setResult('Simulated change failed. Previous local setting remains; retry is available.'); }}>Fail local switch change</Button></div>}
<Switch label="Disabled on sample" checked disabled />
<Switch label="Show local example details" checked={details} onCheckedChange={setDetails} aria-controls="local-switch-details" />
<div id="local-switch-details" hidden={!details}>These English-only local details appear immediately. No apply action or request is required for this display setting.</div>
<p dir="auto">The stable label names the feature. The checked state describes the retained setting, not the requested change.</p>
</div>;
}
API reference
| Prop | Contract |
|---|---|
| label | Optional visible string and default accessible name; provide a meaningful name when omitted. |
| checked / defaultChecked | Controlled boolean / initial uncontrolled boolean; default unchecked. |
| onCheckedChange | (boolean, eventDetails); details.cancel() can reject the change. Use this rather than native onChange. |
| disabled / readOnly / required | false defaults; unavailable, immutable and required are different states. |
| name / uncheckedValue | Form field name; unchecked is omitted unless an explicit uncheckedValue is supplied. |
| inputRef | Ref to hidden HTMLInputElement. The public control ref is HTMLElement, a span by default. |
| aria-label / aria-labelledby / aria-describedby / aria-invalid | Connect a stable name, concise explanation and current error. |
| className / style / render / native props | Installed primitive props; styled wrappers retain string-or-state class callbacks. A custom render must preserve semantics, events and refs. |
| children | Wrapper owns the indicator/thumb. Supply visible text through label and supporting content nearby. |
Behavior and keyboard
- Space changes a mutable focused switch; verify any optional Enter behavior in the installed primitive/browser.
- Do not change the label from Enable reminders to Disable reminders; use a stable feature name and actual checked state.
- Pending requested state and retained state are different values. Commit the actual successful result; failure keeps the prior value and a retry path.
- After outcome controls disappear, return focus to the enabled switch. Disabled is distinct from read-only pending state.
- If used in a form, inspect the actual hidden input name and uncheckedValue behavior rather than submitting only a visual state.
Failure cases
- Optimistically announcing a scheduled reminder when no scheduling occurred.
- A white/dark thumb that is invisible against its track or a track lacking a meaningful boundary.
- A stale checked value changed again while the first request is pending.
- Disappearing outcome controls leaving focus on the document body.
Verification and release scope
- Check actual names, roles, state and focus with pointer and keyboard. Inspect the retained value and submitted FormData when relevant.
- Exercise empty, selected, mixed, disabled, read-only, invalid and pending/recovery states that apply to the control. Styling alone does not prove enforcement.
- Check both themes, long labels, 320px reflow, meaningful graphic/text contrast, reduced motion, forced colors and the actual target device.
- Use a named screen-reader/browser pair to verify spoken state. DOM assertions and local examples do not prove announcements or backend persistence.
- Selection never establishes language quality, source authority, consent, qualified review or permission. Keep that evidence explicit in the product task.