InputOTP
A segmented one-time-code field with sequential entry, backspace deletion, and paste distribution.
Overview
InputOTP is a segmented field for one-time codes — a row of single-character cells backed by one
left-filled string value. Use it for the verification codes sent by email or SMS and for
authenticator (TOTP) entry. Typing advances through the cells, Backspace deletes the last
character, and pasting a code distributes it across the cells.
Anatomy
A labeled role="group" wrapping length cells. Each cell is a bare, single-character <input>
inside its own sketchy SketchSurface box: the active cell shows the focus stroke, filled cells a
stronger stroke, and an invalid field paints every cell in the danger stroke. Every color, size,
and sketch parameter comes from @ghds/tokens (comp.inputOtp.*).
Variants & States
No named variants — instead length, mode, and mask shape the field, and disabled / invalid
set its state. Cells also react to being active (focused) and filled.
| State | Status | Notes |
|---|---|---|
| Active | Implemented | The focused cell shows comp.inputOtp.stroke.active. |
| Filled | Implemented | Filled cells show a stronger stroke than empty ones. |
| Invalid | Implemented | The invalid prop paints every cell in the danger stroke. |
| Disabled | Implemented | — |
| Masked | Implemented | mask renders each cell as a password field. |
| Loading | Not implemented | — |
Usage
Do
- Use onComplete to submit or verify once the final cell is filled, rather than polling onChange.
- Keep mode="numeric" (the default) for digit-only codes so mobile keyboards show the number pad.
- Provide a label (or aria-label) so the group has an accessible name.
Don't
- Don't use InputOTP for general text entry — it's for short, fixed-length codes only.
- Don't set a length that doesn't match the code you send; entry is capped at length.
Accessibility
- The cells are wrapped in a
role="group"named by thelabel(via RadixLabel) or thearia-label, so assistive technology announces them as one field. - Each cell carries its own positional
aria-label(Digit 1,Digit 2, …), and the inner inputs useautoComplete="one-time-code"so mobile OSes can offer the received code. - Keyboard support is built in: typing advances, Backspace deletes the last character, and Arrow/Home/End move between cells.
- Pair
invalidwith an external error message so the reason is explained, not only colored. - See the Accessibility Guide for shared conventions.
Content
There’s no free text — content is the code itself. Keep the accompanying label short (Verification code) and put any “resend” affordance or error message outside the field.
Props API
React@ghds/react
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of segments/cells. |
value | string | — | Controlled value (a left-filled prefix, never longer than length). |
defaultValue | string | '' | Initial value when uncontrolled. |
onChange | (value: string) => void | — | Fires on every change with the full current value. |
onComplete | (value: string) => void | — | Fires once the last cell is filled, with the complete code. |
mode | 'numeric' | 'text' | 'numeric' | Accepted characters; 'numeric' restricts to digits. |
mask | boolean | false | Masks the entered characters like a password. |
disabled | boolean | false | Disables interaction. |
invalid | boolean | false | Marks every cell invalid (danger stroke). |
label | string | — | Visible label, associated with the group via Radix Label. |
aria-label | string | — | Accessible name when no visible label is provided. |
Web Components@ghds/web-components
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of cells. |
value | string | '' | Current value (a left-filled prefix). |
mode | 'numeric' | 'text' | 'numeric' | Accepted characters. |
mask | boolean | false | Masks the entered characters. |
disabled | boolean | false | Reflected attribute; disables entry. |
invalid | boolean | false | Reflected attribute; danger stroke on every cell. |
label | string | — | Optional visible label, associated with the first cell. |
events | gh-change, gh-complete | — | CustomEvents (detail: { value }) — gh-change on every edit, gh-complete once all cells are filled. |
React Native@ghds/react-native
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of cells. |
value | string | — | Controlled value. |
defaultValue | string | '' | Initial value when uncontrolled. |
onChange | (value: string) => void | — | Fires on every change. |
onComplete | (value: string) => void | — | Fires once the last cell is filled. |
mode | 'numeric' | 'text' | 'numeric' | Accepted characters; drives the keyboardType. |
mask | boolean | false | secureTextEntry for the cells. |
disabled / invalid | boolean | false | Disable, or mark every cell invalid. |
label / accessibilityLabel | string | — | Visible label, or an accessible name for the group. |
testID | string | — | Test handle (applied to the cell group). |
Code examples
// React — @ghds/react
import { InputOTP } from '@ghds/react/input-otp';
<InputOTP
label="Verification code"
value={code}
onChange={setCode}
onComplete={(code) => verify(code)}
/>;
// Invalid state:
<InputOTP label="Invalid code" defaultValue="123456" invalid />;
<!-- Web Components — @ghds/web-components -->
<script type="module">
import '@ghds/web-components/input-otp';
</script>
<gh-input-otp id="otp" label="Verification code"></gh-input-otp>
<script>
// Controlled-only; it dispatches gh-change on every edit and
// gh-complete once every cell is filled (detail: { value }).
document.getElementById('otp').addEventListener('gh-complete', (e) => {
verify(e.detail.value);
});
</script>
// React Native — @ghds/react-native
import { InputOTP } from '@ghds/react-native/input-otp';
<InputOTP label="Verification code" onComplete={(code) => verify(code)} />;
Live Demo
React and Web Components render live below, driven by the same tokens — toggle dark mode in the
header to see both respond identically. React Native can’t run in a browser without additional
react-native-web wiring (not set up in this site), so its usage is shown as a code sample instead.