> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/input-code.html
> Source: specs/components/input-code/input-code.md, specs/components/input-code/input-code-a11y.md, specs/components/input-code/capabilities.json

---

# InputCode · Oymyakon DS 3

> A row of cells a one-time confirmation code is typed into — sign-in, account access, confirming a
> sensitive action.

**Version:** 3.0.1 · **Status:** Ready · **Figma:** `[InputCode] 3.0` (main components file `7vdl5YkZFDWvh9QvSmydsH`, page `17085:1057`; the public master `[InputCode] 3.0` `17085:1059` (key `433b5f0b…`) — the `s8` / `s16` wrapper hosting the set `InputCode` `17091:4151` (key `36e63710…`): RTL × Disabled × Error × Skeleton × Success × Read only + the boolean `Helper`; its building blocks `.🟣Input` `17088:361` (the cell: State × Disabled × Error × Success × Skeleton × Masked × Read only), `.🟣InputCode` `17088:387` (the row, cells 05–08 behind booleans) and `.🟣Helper` `17091:4036` (Error × Disabled × RTL); the Specification frame `17091:9320`; the motion prototypes in the section `input-code-anim` `17961:6358`)

---

## 1. Description

InputCode is the field a one-time confirmation code is entered into (Figma: "It is used for user
authentication, account access, or confirming sensitive actions"). The code is shown one character per
cell; the cells fill left to right, and when the last one is filled the code is checked.

Two presets (the frame: "Component has two presets"):

- **Unmasked** — the default: each digit is visible, "for easier input and verification (e.g.,
  dictation or manual entry)". For a code sent by SMS, e-mail or a call.
- **Masked** — each digit is drawn as a dot, "recommended for sensitive data … (e.g., card PIN,
  banking details)".

The component draws the cells and the helper line; checking the code is the host's. The host sets the
state the check produces — Validation while it runs, then Error or Success.

### Platform implementations

| Platform | Entry point | Version | Notes |
|---|---|---|---|
| iOS | `DsInputCode` (DS3) | 3.0 | `init(text: Binding<String>, state: Binding<DsInputCodeState>)` — the caller owns both; `length(_:)`, `isSecure` (Masked), `isReadOnly`, `contentType`, `alignment`, `keepsKeyboard`, `helpText` / `errorText`, `onComplete`, `onSuccessAnimationEnd`, `onResetAnimationEnd` as modifiers. `DsInputCodeState.loading` is the Validation shimmer; `.isLoading(true)` is Skeleton |
| Android | `DsInputCode` (`core.compose.ds.v3.input_code`) | 3.0 | `code: TextFieldState`, `codeLength` (default 4), `onInputDone`; one `state` enum — Enabled, Disabled, Error, Skeleton, Success, ReadOnly; `helper: DsInputCode.Helper?`; `style = Default`, `size = M` |
| Flutter | — | — | Not shipped |
| Web | `src/shared/shared.css` (`.input-code`) | 3.0.0 | Reference implementation, this repo; preview page `src/input-code.njk` |

Recorded platform divergences:

- **Validation** — a state on iOS (`.loading`) and on the web; Android's state enum has no value for it.
- **Masked** — iOS `isSecure`; Android's bridge lists no masking parameter.
- **The end of the animations** — iOS reports them (`onSuccessAnimationEnd` — the moment to move on —
  and `onResetAnimationEnd`); the web has no event of its own: the host times the next step.
- **Alignment** — an iOS modifier and a web axis; Android's bridge lists none.
- **One value, one field.** Both platforms hold the code in one text value (`Binding<String>`,
  `TextFieldState`) and draw the cells from it; the web does the same (§ 5, the owner's choice
  2026-11-06).

---

## 2. Anatomy

```
[InputCode] (public master)              outer margins s8 vert / s16 horiz — on the wrapper, not on the root
InputCode (.input-code)                  fills the host's width
├── Field (.input-code__field)           the cells row and the one input over it
│   ├── Input (.input-code__input)       one native <input>, transparent, over the cells — the value and the focus
│   └── Cells (.input-code__cells)       s4 apart · aligned start / center / end · always left to right
│       └── Cell (.input-code__cell) ×N  s56 × s56 · radius s20 (s12 from 8 cells) · s2 inside border
│           ├── Character (.input-code__char)   Heading 2 — Unmasked
│           └── Dot (.input-code__dot)   dot s20 — Masked
├── Helper (.input-code__helper)         s12 under the cells · optional, off by default; Error always shows it
│   ├── Error icon                       warning-filled s16 · s4 before the text · the error form only
│   └── Text (.input-code__helper-text)  Compact Body · wraps
└── Status (role="status")               visually hidden · the announcements (web)
```

| Part | Required | Description |
|---|---|---|
| **Root** | Required | Fills the host's width; the cells sit inside it at the alignment (§ 3). The public master's `s8` / `s16` margins belong to the wrapper — on the web they are the host's (the TextField precedent) |
| **Field** | Required | The cells row and the input over it — the one touch area |
| **Input** | Required | One native text input stretched over the cells and drawn transparent: it holds the code, takes the focus, opens the numeric keyboard and receives a paste or an SMS code (§ 5). The cells only draw its value |
| **Cells** | Required | "Displays four input fields by default"; "The component can display an unlimited number of input fields". `s4` apart; the row runs left to right in RTL too |
| **Cell** | Required | `s56` × `s56`, radius `s20`, an `s2` border inside. "If the number of inputs exceeds the device screen width, the input widths decrease to fit"; "The corner rounding size of inputs decreases starting from 8 inputs" — to `s12` |
| **Character** | Unmasked | The character, Heading 2, centred (Figma `input-text`) |
| **Dot** | Masked | The `dot` icon at `s20` in place of the character |
| **Helper** | Optional | Figma `Helper` (off by default): a line of text `s12` under the cells — "Long text is displayed in full by default. Long text wraps to the next line". The input's description. Error shows its text in it even with the Helper off — a wrong code always says so |
| **Error icon** | The error form | `warning-filled` at `s16`, `s4` before the text, "aligned to the first line of the text" |
| **Status** | Required (web) | A visually hidden `role="status"` element, mounted from the start: the error text, "Checking the code" and "Code accepted" are written into it once each (`input-code-a11y.md`) |

### RTL layout

"The component elements are right-aligned. The sequence of code entry remains LTR. Do not mirror it."
The root's direction (`dir="rtl"` on it or an ancestor) moves the cells and the helper to the right and
puts the error icon after the text, on its right; the cells keep their left-to-right order — the first
character stays in the leftmost cell. Alignment follows the reading direction: Start is the right edge
in RTL.

---

## 3. Variants and sizes

| Axis | Values | Default | Web mechanism |
|---|---|---|---|
| **Preset** | `unmasked` · `masked` | `unmasked` | `.input-code--masked` marks it — the host draws a `.input-code__dot` per character and makes the input `type="password"`. `data-ds-preset` |
| **Length** | 4–8 cells | 4 | The `.input-code__cell` count and the input's `maxlength`. From 8 cells the radius is `s12` (CSS counts the cells) |
| **Alignment** | `start` · `center` · `end` | `start` | `.input-code--align-center` / `.input-code--align-end` — the cells and the helper text together |
| **Helper** | `on` · `off` | `off` | The `.input-code__helper` element, present or not; its text is the host's |
| **State** | see § 4 | `standard` | `.input-code--{state}`; `data-ds-state` |
| **RTL** | `off` · `on` | `off` | `dir="rtl"` on the root or an ancestor |

- One size (Android `Size.M`). The cell is `s56` wide at most and narrows when the row has no room.
- Figma stages lengths 4–8 in `.🟣InputCode` (cells 05–08 behind booleans) — the contract's range. The
  frame says "The component can display an unlimited number of input fields" and the platforms take any
  length (`length(_:)`, `codeLength`); past 8 the cells crowd the row (Usage context).
- The Specification frame's Alignment block shows Start ("by default"), Center and End; the set has no
  Alignment property — it is a layout choice of the instance.

---

## 4. States

The frame: "InputCode states" — Skeleton, Rest empty, Active empty, Active filled, Rest filled,
Validation, Error, Success, Disabled (filled), Disabled (empty), Read only. "States such as Disabled,
Skeleton the same for all presets."

| State | Figma | What shows |
|---|---|---|
| **Standard** | Rest empty | Empty cells in `Surface/OnWhite`, no border |
| **Active** | Active empty / Active filled | The input has the focus: the next cell to fill takes the `Border/Active` border (the first empty one; the last cell when all are filled). "The active field has no cursor indicator" |
| **Filled** | Rest filled | Characters in the cells, no focus |
| **Validation** | (the frame's state 6) | The code is being checked: a shimmer runs over the cells — "using the Skeleton WhiteOverlay style". The input is read-only |
| **Error** | Error | Every cell takes the `Border/Error` border, the cells shake once, and the helper turns into the error form — `warning-filled` + the error text in `TextAndIcon/Error`, shown even with the Helper off. Default text "Incorrect code. Please try again"; "The user can use a custom error text" |
| **Success** | Success | Every cell takes the `Border/Success` border; "you can navigate the user to the next view". The input is read-only |
| **Disabled** | Disabled (empty / filled) | Characters and the helper in `TextAndIcon/Disabled`; no border, no focus |
| **Read only** | Read only | The code shown, not editable: the cells in `Surface/WhiteOverlay` with a `Border/Default` border |
| **Skeleton** | Skeleton | The cells as placeholders with the DS shimmer; no characters, no helper |

- Standard, Active and Filled follow the focus and the value; the host sets the others.
- Validation, Success and Read only keep the focus with no cell active. On the web the cells row takes
  a `Border/Active` outline then (`s2`, `s4` out) — the focus always shows. Figma has no focus design
  for them; the outline is the web's — accepted as the DS contract (owner, 2026-11-12).
- After Error the code is cleared (§ 5): the cells return to Standard with the first one active, and
  the error text stays until a character is typed.
- Figma flags:
  - The Success variant is named `Error=on, Success=on` — both on; the web reads it as Success.
  - Validation is a state of the frame and of the motion prototypes, not a variant of the set. Its
    colour list names `Border/Success` for the input border; its specimen shows none, and the web
    draws none.
  - The active Disabled and Skeleton cells bind an `s2` border weight with no paint — no border shows.

---

## 5. Animation and behavior

Reference: [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). The frame's "Validating process
(animation)" block and the `input-code-anim` prototypes give the movements and their numbers.

| Event | Pattern | Token | Notes |
|---|---|---|---|
| A cell's border appears or changes (active, error, success, back to empty) | `Patterns/Color` | `var(--pattern-color)` | `box-shadow` (the inside border) and `color` — "A green border appears around the fields (200ms / 0.25, 0.25, 0.75, 0.75)", "the fields transition to the empty state (200ms / 0.25, 0.25, 0.75, 0.75)": Patterns/Color exactly |
| The code is being checked (Validation) | `Patterns/Shimmer` | `var(--pattern-shimmer)` | The DS wave over each cell — "using the Skeleton WhiteOverlay style" |
| A wrong code (→ Error) | `Patterns/Shake` | `var(--pattern-shake)` | The cells row swings right `s8`, left `s8`, right `s4` and settles — a damped swing in four eased legs, 300ms. The token was added to the DS for this component (Motion 1.8.0, owner 2026-11-06). The helper does not move |
| Skeleton | `Patterns/Shimmer` | `var(--pattern-shimmer)` | The DS wave over each placeholder; it runs right to left in RTL (the Skeleton precedent) |

- **No motion:** a character appearing or leaving, the dot, the helper text and its error form — they
  change at once.
- **Reduced motion.** Every token above is zeroed at the token — the border changes at once, the
  shimmer and the shake do not play. With no swing to wait for, the red border passes at once; the
  error is the error text and its icon, announced once. The stilled shimmer sits behind the
  characters, not over them.
- Figma flag: the prototype `.inputcode-error-anim` swings six times — 25ms legs of ±16 on
  `0.25, 0.25, 0.75, 0.75` (the fifth on `0.4, 0, 0.2, 1`), "The fields move left and right 6 times".
  It read as a jitter; the owner chose a softer, damped swing (2026-11-06) — the shape of the
  transitions.dev "Error state shake" on DS primitives. The prototype is the owner's to update.

### Data entry

From the frame's "Data entry process" block (written in Russian in the frame — flagged).

1. Tapping the component activates the first empty cell — also when some cells are already filled.
2. Tapping a filled cell clears its character; only that character is cleared.
3. A character typed into an empty cell activates the next empty cell, skipping the filled ones.
4. Entry is sequential: a character typed while an earlier cell is still empty moves into the first
   empty cell.
5. **Paste.** "Users can copy the code to the clipboard and paste it — all the fields are filled
   automatically." A long tap on any cell offers Paste when the clipboard holds a code. A paste fills
   from the first cell and replaces what was typed.
6. **Delete** — "The character is erased only in the active field." On the web Delete clears the
   character before the active cell, as in any text field; the platforms hold one text value too.
   Typing over a full selection replaces the code; Delete over it, or a word delete, clears it.
7. **Keyboard** — "Use only a numeric keyboard" (the frame shows the Android and iOS numeric
   keyboards). Only digits 0–9 are accepted.

On the web the one input carries all of it: `inputmode="numeric"` opens the numeric keyboard,
`autocomplete="one-time-code"` offers the code from an SMS (Unmasked), the native long-press menu
offers Paste, and the cells only draw the value. A tap on a filled cell reaches the input too; the
reference script on the preview page maps it to the cell under the finger — for a real pointer only,
so a screen reader's activation (at the field's centre) clears nothing. A cleared cell leaves a gap
the next digit fills; until then the input's value is the typed digits in order, without the gap.

### Validation

"Events are provided for the validation behaviour: 1. By default — validation starts automatically when
all the fields are filled. 2. Alternatively — the user starts it (for example, by pressing a button)."
(iOS `onComplete`, Android `onInputDone`.)

- **Error** — Validation → Error: the red border and the shake, then "the fields transition to the
  empty state", "The first field becomes active", and "The Helper Text Error element hides or is
  replaced by the default Helper Text when the user types a character in the active field".
- **Success** — Validation → Success: the green border; "After this animation, you can navigate the
  user to the next view".
- **The keyboard** — on a screen, the keyboard hides while the code is checked; in a Bottom Sheet it
  stays, and the screen's other elements are blocked until the check ends (Usage context; iOS
  `keepsKeyboard`). The focus stays on the field either way: a host that hides the keyboard by taking
  the focus away gives it back to the field after an Error, not to the page.

---

## 6. Color tokens

| Element | Token | Notes |
|---|---|---|
| Cell fill | `var(--surface-on-white)` | Every state but Read only — Skeleton too (Figma binds Surface/OnWhite, the same values as Skeleton/OnWhite) |
| Cell fill — Read only | `var(--surface-white-overlay)` | |
| Border — Active | `var(--border-active)` | The next cell to fill |
| Border — Error | `var(--border-error)` | Every cell |
| Border — Success | `var(--border-success)` | Every cell |
| Border — Read only | `var(--border-default)` | Every cell |
| Character / dot | `var(--text-and-icon-primary)` | |
| Character / dot — Disabled | `var(--text-and-icon-disabled)` | |
| Validation wave | `var(--skeleton-wave)` | The DS shimmer gradient |
| Helper text | `var(--text-and-icon-primary)` | |
| Helper — error form (icon and text) | `var(--text-and-icon-error)` | |
| Helper — Disabled | `var(--text-and-icon-disabled)` | |
| Focus outline (Validation, Success, Read only) | `var(--border-active)` | Round the cells row, web only (§ 4) |

- Error and Success are not colour alone: Error adds the error icon and text (and the shake); Success
  hands over to the next view.

---

## 7. Typography

| Element | Style | Tokens | Scaling |
|---|---|---|---|
| Digit | Heading/Heading 2 — 24/28, PP Agrandir Bold | `var(--text-heading-heading2-*)` | `cap130` (Figma binds `SP/130% ratio`) |
| Helper text | Body/Compact Body — 14/16, Suisse Intl Book | `var(--text-body-compact-body-*)` | `full` (Figma binds `SP/150% ratio`) |

- The frame's Resizing block (100% / 130% / 150%): the cells keep their size; the digit grows to 130%,
  the helper text to 150% and wraps.
- The helper text aligns with the cells — start, center or end.

---

## 8. Spacing

Each value takes the SP family Figma binds: `SP/100% ratio` → `var(--sp-100-sN)` (fixed),
`SP/130% ratio` → `var(--sp-130-sN)`, `SP/150% ratio` → `var(--sp-sN)` (full).

| Property | Token | @100% | Figma | Notes |
|---|---|---|---|---|
| Cell — width (max) / height | `var(--sp-100-s56)` | 56 | `SP/100% ratio/s56` | "s56 — input width / height"; the width narrows when the row has no room |
| Cell — radius | `var(--sp-100-s20)` | 20 | `SP/100% ratio/s20` | "s20 — corner size" |
| Cell — radius, 8 cells and more | `var(--sp-100-s12)` | 12 | `SP/100% ratio/s12` | "decreases starting from 8 inputs" |
| Cell — border | `var(--sp-100-s2)` | 2 | `SP/100% ratio/s2` | Inside the cell |
| Between cells | `var(--sp-100-s4)` | 4 | `SP/100% ratio/s4` | "spacing between inputs" |
| Cells ↔ helper | `var(--sp-100-s12)` | 12 | `SP/100% ratio/s12` | The helper's top padding |
| Error icon ↔ text | `var(--sp-100-s4)` | 4 | `SP/100% ratio/s4` | |
| Error icon | `var(--sp-130-s16)` | 16 | `SP/130% ratio/s16` | "s16 — helper icon size"; the glyph inside binds `SP/150% ratio` — the container wins (flagged) |
| Dot (Masked) | `var(--sp-100-s20)` | 20 | unbound | "s20 — IconContainer, 'dot' icon" |
| Outer margins (public master) | `var(--sp-100-s8)` / `var(--sp-100-s16)` | 8 / 16 | `SP/100% ratio` | Vertical / horizontal, on the wrapper — the host's on the web |

---

## 9. Usage context

### When to use InputCode

- To confirm a phone number or an e-mail with a code sent to it — Unmasked.
- To enter a PIN or another secret code — Masked.
- To show a code that cannot be changed — Read only.

### When not to use

- For a password or any free-form text — PasswordField, TextField.
- For a phone number — PhoneField.
- For a number the person picks rather than types — NumericStepper, PinwheelPicker.

### Placement

- "When displayed on the screen, hide the keyboard during the validation of the entered code."
- "When placed in a BottomSheet, do not hide the keyboard, but block interactions with elements on the
  screen during the code validation process."
- A heading over it says what the code is for ("Enter the confirmation code"), and the helper line says
  what it accepts ("Numbers 0 to 9 allowed").

### Related components

- [TextField](https://super-dollop-pzmo65r.pages.github.io/text-field.md) — the same helper line and the same skeleton wave.
- [PasswordField](https://super-dollop-pzmo65r.pages.github.io/password-field.md) — a secret of free length.
- [Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md) — the shimmer the Skeleton and Validation states run.

---

## 10. Accessibility

Brief summary. Full spec: [`input-code-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/input-code.md).

- **One field.** Assistive technology meets one text field — "Enter code" — with the helper text as its
  description and the typed digits as its value; the cells are hidden from it.
- **Numbers only** — the numeric keyboard, and an SMS code offered for Unmasked.
- **Masked** is a secure field — the digits are not read out.
- **Errors are announced** — the field turns invalid and the error text is read once when it appears,
  Helper on or off; the red border and the shake are not the only signal.
- **The check is announced** — "Checking the code", then "Code accepted" (a DS addition).
- **The focus is always seen** — the active cell, or an outline round the row when no cell is active.
- **Text scaling** — the digit to 130%, the helper text to 150%.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `input-code` |
| Coverage unit | yes — one root, one DS component instance |
| Tap target model | The field is one target — the one input over the cells (the TextField precedent) |
| Actions | `focus` on the input |
| Internal targets | none |
| Emits value | **No — not the code** — at most empty / filled and the state the check produced |

Attribute model: `data-ds-preset` = `unmasked` or `masked`; `data-ds-state` = `standard`, `validation`,
`error`, `success`, `disabled`, `read-only` or `skeleton` (the host-set state — Active and Filled follow
the focus and the value). RTL is the reading direction, not a state.

```html
<!-- Unmasked, 4 cells, with the helper — the one input over the cells holds the code,
     named by the screen's heading -->
<h1 id="sms-code-title">Enter the confirmation code</h1>
<div class="input-code"
     data-ds-component="input-code" data-ds-component-id="auth.sms-code"
     data-ds-preset="unmasked" data-ds-state="standard">
  <div class="input-code__field">
    <input class="input-code__input" id="sms-code" type="text"
           inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="4"
           aria-labelledby="sms-code-title" aria-describedby="sms-code-helper"
           data-ds-action="focus">
    <div class="input-code__cells" aria-hidden="true">
      <span class="input-code__cell"></span><span class="input-code__cell"></span>
      <span class="input-code__cell"></span><span class="input-code__cell"></span>
    </div>
  </div>
  <div class="input-code__helper" id="sms-code-helper">
    <span class="input-code__helper-text">Numbers 0 to 9 allowed</span>
  </div>
  <span class="visually-hidden" role="status"></span>
</div>

<!-- Error — every cell red, the helper in its error form, the input invalid -->
<div class="input-code input-code--error" … data-ds-state="error">
  <div class="input-code__field">
    <input class="input-code__input" … aria-invalid="true">
    <div class="input-code__cells" aria-hidden="true">…</div>
  </div>
  <div class="input-code__helper input-code__helper--error" id="sms-code-helper">
    <svg aria-hidden="true" …>warning-filled</svg>
    <span class="input-code__helper-text">Incorrect code. Please try again</span>
  </div>
  <span class="visually-hidden" role="status">Incorrect code. Please try again</span>
</div>

<!-- Skeleton — not a field yet: hidden from assistive technology -->
<div class="input-code input-code--skeleton" … data-ds-state="skeleton">
  <div class="input-code__field" aria-hidden="true">
    <input class="input-code__input" … disabled>
    <div class="input-code__cells" aria-hidden="true">…</div>
  </div>
  <span class="visually-hidden" role="status"></span>
</div>
```

The host fills the cells from the input's value (a `.input-code__char` per cell, a `.input-code__dot`
per character when Masked), marks the next cell to fill with `.input-code__cell--active` while the input has
the focus, sets the state class, and writes each announcement into the status element once. Without a
visible heading the input is named by `aria-label="Enter code"`. The preview page's script is the
reference.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.0.1 | 2026-11-12 | Owner ruling and Status Draft → Ready (owner). The DS's additions are accepted as the contract: the focus outline on the cells row in Validation, Success and Read only (`Border/Active`, `s2`, `s4` out), and the "Checking the code" / "Code accepted" announcements with the way the error is announced. The device checks and the Figma-side flags stay open. |
| 3.0.0 | 2026-11-06 | Initial spec, from the Figma `[InputCode] 3.0` page (`17085:1057`): the public master `17085:1059` (the `s8` / `s16` wrapper), the set `InputCode` `17091:4151` (RTL × Disabled × Error × Skeleton × Success × Read only + `Helper`), its building blocks (the cell `.🟣Input`, the row `.🟣InputCode`, `.🟣Helper`), the Specification frame `17091:9320` and the motion prototypes `input-code-anim`. Presets Unmasked / Masked; 4–8 cells, four by default; Alignment start / center / end; nine states with Validation from the frame. Web: one native input over the cells (the owner's choice) — numeric keyboard, SMS code, paste, one field for assistive technology. Motion on named tokens — the border on `Patterns/Color`, the shimmer on `Patterns/Shimmer`, the wrong-code shake on the new `Patterns/Shake` (Motion 1.8.0). Spacing takes the SP family Figma binds: the cells and the gaps fixed, the digit cap130, the helper text full, the error icon cap130. Both platforms ship `DsInputCode` 3.0; divergences recorded. Review pass (a11y + spec, same day): the focus shows in every state (an outline round the row where no cell is active); Error always shows and announces its text through a mounted status element, Helper on or off; "Checking the code" / "Code accepted" announced; Skeleton hidden from assistive technology; the field named by the visible heading; RTL alignment corrected (Start = the right edge); the Validation class `--validation`; Masked switches off Edge's reveal control; the stilled shimmer sits behind the digits; tap-to-clear only for a real pointer, and a full selection replaced or cleared whole. |

---

# InputCode — Accessibility

InputCode is a text field drawn as a row of cells. The Specification frame voices it as a text field —
"Enter code, Numbers 0 to 9 allowed, Slot 1 of 4, Text field, Is editing." — named by the input's
description, described by the helper text, with the code as its value:

- **Label** — "[Input Description], [Helper text]": the host's name for the code ("Enter code"), then
  the helper line ("Numbers 0 to 9 allowed").
- **Trait** — a text field. The keyboard is numeric.
- **The cells are not separate elements** on the web: they draw one field's value (§ 5 of the spec — the
  owner's choice, 2026-11-06), so assistive technology meets one field, not N.

| Visible | Announced (VoiceOver, the frame) |
|---|---|
| Empty, focused (Active empty) | "Enter code, Numbers 0 to 9 allowed, Slot 1 of 4, Text field, Is editing." |
| A digit typed (Active filled) | "Five, Numbers 0 to 9 allowed, Slot 1 of 4, Text field, Is editing." |
| Masked, a digit typed | "Filled, Numbers 0 to 9 allowed, Slot 1 of 4, Text field, Is editing." |
| Error | "Enter code, Has error: Incorrect code. Please try again. Numbers 0 to 9 allowed, Slot 1 of 4, Text field, Is editing." |
| Disabled | "Enter code, Numbers 0 to 9 allowed, Text field, Dimmed" |
| Validation (the frame's "Loading state") | "Enter code, Text field, Editing disabled." |

The frame voices these six. Success, the "Checking the code" and "Code accepted" announcements, and the
way the error is announced (below) are the DS's additions — accepted as the DS contract (owner, 2026-11-12).

---

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Field | `contentDescription` "[Input Description]" | The typed digits, read digit by digit; Masked — the count only (a password field) | Edit box | The helper text (`supportingText` / `stateDescription`) |
| Field — Error | the same | the same | Edit box, `error` set to the error text — read as "Error: Incorrect code. Please try again" | — |
| Helper line | — read with the field as its supporting text, not a stop of its own | | | |
| Cells, dots, error icon | — (`clearAndSetSemantics {}` on the drawing) | | | |

### Edge states

- **Disabled:** reachable, announced "Disabled"; it does not open the keyboard.
- **Read only:** reachable; the code is read, the keyboard does not open.
- **Validation:** the field does not take input while the code is checked; "Checking the code" is
  announced politely (a DS addition).
- **Success:** "Code accepted" is announced politely (a DS addition), unless the app moves on to the
  next view at once.
- **Error:** the error text is announced once when it appears — the field's `error`, with
  `liveRegion = Polite` on the error text so it is read without a focus move; it clears when a digit is
  typed. The error text shows even when the Helper is off.
- **Skeleton:** not announced as a field — the screen's loading announcement covers it (the Skeleton
  contract).
- **RTL:** the field reads in the language's order; the code keeps its left-to-right order.
- **Text scaling:** the digit to 130%, the helper text to 150%.

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Field — empty | "[Input Description], [Helper text]" — "Enter code, Numbers 0 to 9 allowed" | — ("Slot 1 of 4" — the position the next digit goes to) | Text field | "Is editing." |
| Field — a digit typed | "[Digit], [Helper text]" — "Five, Numbers 0 to 9 allowed" | "Slot N of 4" | Text field | "Is editing." |
| Field — Masked | "Filled, [Helper text]" | "Slot N of 4" | Text field | "Is editing, Character mode, Insertion point at start, …" (the system's secure-field hint) |
| Field — Error | "[Input Description], Has error: [Error text]. [Helper text]" | "Slot N of 4" | Text field | "Is editing." |
| Field — Success | "[Input Description], [Helper text]" | the code | Text field | — |
| Helper line | — `accessibilityHidden(true)`: its text is already in the field's label | | | |
| Cells, dots, error icon | — `accessibilityHidden(true)` | | | |

### Edge states

- **Disabled:** "Dimmed"; reachable with swipe navigation, not editable.
- **Validation:** "Editing disabled" — the field is read, the keyboard does not take input;
  "Checking the code" is posted as an announcement (a DS addition).
- **Success:** "Code accepted" is posted as an announcement (a DS addition), unless the app moves on at
  once.
- **Error:** a label that changes on a focused field is not spoken, so the field posts
  `UIAccessibility.post(notification: .announcement, argument: errorText)` as it enters Error — once.
  The error text shows even when the Helper is off.
- **Read only:** the code is read; the field takes no input, as on Android.
- **Skeleton:** not announced as a field — the screen's loading announcement covers it.
- **RTL / Dynamic Type:** as on Android.

---

## Web preview (reference)

- **One field.** A native `<input>` stretched over the cells, drawn transparent, holds the code and the
  focus. The cells, the dots and the error icon are `aria-hidden="true"`.
- **Name and description.** The input is named by `aria-labelledby` pointing at the visible heading over
  it — the default, so the spoken name matches the seen one (WCAG 2.5.3) — and by `aria-label`
  ("Enter code") only when there is no heading. `aria-describedby` points at the helper, so the helper
  text — and the error text that replaces it — is its description. The helper line itself is not a
  live region.
- **Numbers only.** `inputmode="numeric"` opens the numeric keyboard; `pattern="[0-9]*"`; anything that
  is not a digit is dropped. `maxlength` is the number of cells. Typing over a full selection replaces
  the code; Delete over it clears it.
- **The SMS code.** Unmasked: `autocomplete="one-time-code"` — the platform offers the code from the
  message above the keyboard. Masked: `autocomplete="off"`.
- **Masked** is `type="password"` — the browser's secure field: screen readers read the count of
  characters, not the digits. Edge's reveal control is switched off (`::-ms-reveal`), so no click turns
  the field to plain text. Password managers may still offer to save it — `autocomplete="off"` is a
  request, not a guarantee.
- **Paste.** The native long-press (or context) menu on the field offers Paste; the pasted digits fill
  the cells from the first one.
- **Tap to clear.** A tap on a filled cell clears it — for a real pointer only: a screen reader's
  activation lands on the field's centre and leaves the digits alone. A cleared cell leaves a gap the
  next digit fills; until then the value is the typed digits in order.
- **The focus is always seen.** The input's caret is hidden; the next cell to fill takes the `s2`
  `Border/Active` border while the input has the focus. Where no cell is active — Validation, Success,
  Read only — CSS alone (`:focus-visible` on the input, no active cell) draws a `Border/Active`
  outline round the cells row, `s4` out. Every Tab stop shows (WCAG 2.4.7).
- **Announcements.** One visually hidden `role="status"` element, mounted from the start, carries them
  — the error text as Error begins, "Checking the code" as Validation begins, "Code accepted" on
  Success. Each is written once and cleared when a digit is typed.
- **Error.** The input takes `aria-invalid="true"` — set first, whatever the helper — and the error text
  shows in the helper line (mounted for it when the Helper is off) and goes through the status element
  once. The red border and the shake are not the only signal. When a digit is typed, the host clears
  `aria-invalid`, the status text and the error form.
- **Validation / Success / Read only:** the input is `readonly` — it keeps the focus (so a Bottom Sheet
  keeps its keyboard, Usage context) but takes no input. Validation adds `aria-busy="true"`. A host that
  hides the keyboard on a screen by taking the focus away gives it back to the input after an Error,
  not to the page.
- **Disabled:** the input is `disabled` — out of the tab order, not editable.
- **Skeleton:** the field is `aria-hidden="true"` and its input `disabled` — not a field yet; the
  screen's own region announces the loading once (the Skeleton contract).
- **Touch target:** the whole field is one target, `s56` high — above the `s44` / `s48` minimums.
- **Forced colours:** the borders are drawn with `box-shadow`, which forced colours remove; each cell
  gets a `CanvasText` outline instead, the active cell and the focus outline a `Highlight` one. The
  error form keeps its icon and its words.
- **RTL:** `dir="rtl"` right-aligns the cells and the helper; the code stays left to right.
- **Reduced motion:** the border change, the shimmer and the shake are zeroed at the token. With no
  swing to wait for, the red border passes at once — the error is the error text and its icon,
  announced once. The stilled shimmer sits behind the characters, so their contrast holds.

### Contrast

- Digit — `TextAndIcon/Primary` on `Surface/OnWhite`: 15.69:1 Day, 13.2:1 Night.
- Helper text — `TextAndIcon/Primary` on `Background/Primary`: 17.76:1 Day, 17.04:1 Night.
- Error text — `TextAndIcon/Error` on `Background/Primary`: **3.98:1 Day**, 4.91:1 Night — under the 4.5:1
  small-text floor in Day; on `Background/Secondary` about 2.95:1 Day. The DS's status colour, the same
  finding as TextField's error text — flagged.
- Active border — `Border/Active` against `Surface/OnWhite`: 15.69:1 Day, 13.2:1 Night.
- Error border — `Border/Error`: 3.52:1 Day, 3.8:1 Night (non-text, 3:1 floor).
- Success border — `Border/Success`: **2.45:1 Day**, 5.31:1 Night — under the 3:1 non-text floor in Day.
  Success is followed by the next view, so the border is not the only outcome — flagged.
- Empty cell — `Surface/OnWhite` on `Background/Primary`: **1.13:1 Day**, 1.29:1 Night, with no border —
  the cell's edge is faint; TextField's finding too — flagged.
- Read only — the digit 17.76:1 Day on `Surface/WhiteOverlay`; the `Border/Default` edge 1.26:1 Day is
  decoration (the read-only state is told by the field's `readonly`, not by the edge).
- Disabled text is exempt.

---

## Checklist

- [ ] One field is announced, named by the visible heading (or "Enter code") and described by the helper
- [ ] The cells, the dots, the error icon (and, on iOS, the helper line) are hidden from assistive tech
- [ ] The numeric keyboard opens; only digits are accepted
- [ ] Unmasked offers the SMS code; Masked is a secure field with no reveal control
- [ ] The error text shows (Helper on or off) and is announced once; the field is invalid until a digit is typed
- [ ] "Checking the code" and "Code accepted" are announced once each
- [ ] The focus is always visible — the active cell, or the outline round the row
- [ ] Validation and Success take no input and keep the focus; Disabled is out of the tab order
- [ ] Skeleton is not announced as a field
- [ ] The digit scales to 130%, the helper text to 150%
- [ ] The code stays left to right in RTL

---

## Machine contract — `specs/components/input-code/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "input-code",
  "name": "InputCode",
  "version": "3.0.1",
  "description": "A row of cells a one-time confirmation code is typed into — one native input over the cells holds the code and the focus, the cells draw it. Presets Unmasked · Masked (a dot per character); 4–8 cells, four by default; Alignment start · center · end; an optional Helper line with an error form; States Standard · Active · Filled · Validation · Error · Success · Disabled · Read only · Skeleton.",
  "files": {
    "spec": "specs/components/input-code/input-code.md",
    "a11y": "specs/components/input-code/input-code-a11y.md",
    "preview": "src/input-code.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon (components) — main file",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "[InputCode] 3.0": {
        "key": "433b5f0b548c707c0c562fef894d69e93e6164b9",
        "note": "The public master 17085:1059 on page [InputCode] 3.0 (17085:1057): the s8 / s16 wrapper hosting the InputCode set. Its outer margins are the host's on the web (the TextField precedent)."
      },
      "InputCode": {
        "key": "36e63710e04308b143a9840801d1e973b2ddabc6",
        "note": "Set 17091:4151: RTL × Disabled × Error × Skeleton × Success × Read only (12 variants) + the BOOLEAN Helper#17091:2 (default off). The Success variant is named Error=on, Success=on — a Figma slip. Building blocks: the cell .🟣Input 17088:361 (State rest empty · active empty · active filled · rest filled × Disabled × Error × Success × Skeleton × Masked × Read only), the row .🟣InputCode 17088:387 (cells 05–08 behind booleans), .🟣Helper 17091:4036 (Error × Disabled × RTL + Set Helper Text / Set Error Text). The Design Context Bridges record the set by its instance inside the public master, 17088:398 (the same key) — the manifest binds that id."
      }
    },
    "capturedAt": "2026-11-06"
  },
  "root": {
    "class": "input-code",
    "dataDsComponent": "input-code"
  },
  "anatomy": {
    "field": {
      "class": "input-code__field",
      "notes": "The cells row and the one input over it — one touch area."
    },
    "input": {
      "class": "input-code__input",
      "notes": "One native <input> stretched over the cells, transparent (opacity 0, caret hidden): inputmode numeric, pattern [0-9]*, maxlength = the cell count; Unmasked type text + autocomplete one-time-code, Masked type password + autocomplete off. Named by aria-label (\"Enter code\") or aria-labelledby, described by the helper."
    },
    "cells": {
      "class": "input-code__cells",
      "notes": "aria-hidden. s4 apart (--sp-100-s4); always left to right — direction ltr in RTL too."
    },
    "cell": {
      "class": "input-code__cell",
      "notes": "s56 × s56 at most (--sp-100-s56), narrowing when the row has no room; radius s20, s12 from 8 cells (CSS counts them); an s2 inside border. .input-code__cell--active marks the next cell to fill while the input has the focus — the visible focus."
    },
    "char": {
      "class": "input-code__char",
      "optional": true,
      "onlyWhen": {
        "preset": [
          "unmasked"
        ]
      },
      "notes": "The character, Heading/Heading 2 (cap130), centred — Figma input-text."
    },
    "dot": {
      "class": "input-code__dot",
      "optional": true,
      "onlyWhen": {
        "preset": [
          "masked"
        ]
      },
      "notes": "The dot icon (icons/filled/navigation/dot.svg) at s20 (--sp-100-s20) in place of each character."
    },
    "helper": {
      "class": "input-code__helper",
      "optional": true,
      "notes": "s12 under the cells; Body/Compact Body (full), wraps; the input's description (aria-describedby), not a live region. The error form (.input-code__helper--error) adds warning-filled at --sp-130-s16, s4 before the text, aligned to the first line, all in TextAndIcon/Error — and Error mounts it even when the Helper is off."
    },
    "status": {
      "class": "visually-hidden",
      "notes": "One visually hidden role=\"status\" element in the root, mounted from the start: the error text, \"Checking the code\" and \"Code accepted\" are written into it once each and cleared when a digit is typed."
    }
  },
  "axes": {
    "preset": {
      "title": "Preset",
      "type": "enum",
      "values": [
        "unmasked",
        "masked"
      ],
      "default": "unmasked",
      "css": {
        "mechanism": "unmasked = the bare root; .input-code--masked marks Masked — the host draws a .input-code__dot per character and makes the input type=\"password\" + autocomplete=\"off\" (Edge's ::-ms-reveal switched off). data-ds-preset."
      },
      "figma": {
        "kind": "variant-property",
        "property": "Masked",
        "values": {
          "unmasked": "off",
          "masked": "on"
        },
        "notes": "On the cell building block .🟣Input, not on the public set — the frame names the two presets."
      },
      "constraints": [
        "Masked is for a secret the person knows (a card PIN); a code sent to them is Unmasked."
      ]
    },
    "length": {
      "title": "Length",
      "type": "number",
      "min": 4,
      "max": 8,
      "step": 1,
      "default": 4,
      "css": {
        "mechanism": "The number of .input-code__cell elements and the input's maxlength. From 8 cells the radius drops to s12 (.input-code__cells:has(> .input-code__cell:nth-child(8)))."
      },
      "figma": {
        "kind": "layout",
        "notes": "Four by default; .🟣InputCode stages cells 05–08 behind booleans — the contract's 4–8. The frame says \"The component can display an unlimited number of input fields\" and the platforms take any length (iOS length(_:), Android codeLength); past 8 the cells crowd the row."
      }
    },
    "alignment": {
      "title": "Alignment",
      "type": "enum",
      "values": [
        "start",
        "center",
        "end"
      ],
      "default": "start",
      "css": {
        "mechanism": "start = the bare root; .input-code--align-center / .input-code--align-end move the cells and the helper text together. Start is the right edge in RTL."
      },
      "figma": {
        "kind": "layout",
        "notes": "No property on the set; the Specification frame shows Start (by default), Center and End. iOS alignment modifier; not in Android's bridge."
      }
    },
    "helper": {
      "title": "Helper",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "The .input-code__helper element, present or not; its text is the host's."
      },
      "figma": {
        "kind": "variant-property",
        "property": "Helper",
        "notes": "BOOLEAN Helper#17091:2, off by default; .🟣Helper TEXT Set Helper Text / Set Error Text."
      }
    },
    "state": {
      "title": "State",
      "type": "enum",
      "values": [
        "standard",
        "validation",
        "error",
        "success",
        "disabled",
        "read-only",
        "skeleton"
      ],
      "default": "standard",
      "css": {
        "mechanism": "standard = the bare root (Active and Filled follow the focus and the value); .input-code--{state} for validation, error, success, disabled, read-only, skeleton. Error also sets .input-code__helper--error, aria-invalid on the input and the error text in the status element. data-ds-state."
      },
      "figma": {
        "kind": "variant-property",
        "property": "Disabled · Error · Skeleton · Success · Read only",
        "values": {
          "standard": "all off",
          "validation": "— (the frame's state 6, not a variant)",
          "error": "Error=on",
          "success": "Success=on (named Error=on, Success=on)",
          "disabled": "Disabled=on",
          "read-only": "Read only=on",
          "skeleton": "Skeleton=on"
        }
      },
      "constraints": [
        "The host sets every state but Standard; Validation follows the last digit (or the host's own trigger), then Error or Success.",
        "Validation, Success and Read only make the input readonly; Disabled and Skeleton make it disabled.",
        "After Error the code clears, the first cell turns active, and the error text stays until a digit is typed.",
        "Validation, Success and Read only keep the focus with no active cell — the cells row then takes a Border/Active outline (CSS :focus-visible), so the focus is never unseen.",
        "Error always shows its text — the helper line is mounted for it when the Helper is off.",
        "Skeleton hides the field from assistive technology (aria-hidden on .input-code__field); the screen's own region announces the loading."
      ]
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "dir=\"rtl\" on the root or an ancestor: the cells and the helper move to the right edge, the error icon after the text; the cells keep their left-to-right order."
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL"
      }
    }
  },
  "states": {
    "default": [
      "standard",
      "active",
      "filled",
      "validation",
      "error",
      "success",
      "disabled",
      "read-only",
      "skeleton"
    ]
  },
  "constraints": [
    "One native input holds the code; the cells, characters, dots and error icon are aria-hidden drawing — never a field per cell.",
    "The input is named by aria-labelledby the visible heading over it; aria-label \"Enter code\" only when there is none.",
    "Digits only: inputmode numeric; anything that is not a digit is dropped. A paste or an SMS code fills from the first cell.",
    "Entry is sequential: a digit goes to the first empty cell; a tap on a filled cell clears it.",
    "The code stays left to right in RTL — \"Do not mirror it\".",
    "The error is the red border and the error text (announced once) — the shake is never the only signal.",
    "Spacing follows the SP family Figma binds: the cells, gaps and helper spacing fixed (--sp-100-sN); the digit cap130, the helper text full, the error icon cap130.",
    "Motion: the border on Patterns/Color, Validation and Skeleton on Patterns/Shimmer, the wrong code on Patterns/Shake — all zeroed under reduced motion.",
    "The public master's s8 / s16 outer margins are the host's, not the root's."
  ],
  "analytics": {
    "dataDsComponent": "input-code",
    "action": "focus",
    "notes": "Coverage unit — one root; one target, the input (data-ds-action=\"focus\"). Never emits the code — at most empty / filled and the state the check produced. data-ds-preset = unmasked / masked; data-ds-state = standard / validation / error / success / disabled / read-only / skeleton."
  },
  "rtl": {
    "supported": true,
    "notes": "The cells and the helper align to the right; the cells keep their left-to-right order; the error icon follows the text."
  }
}
```
