A cell input renders a fixed-shape entry — a verification code, a PIN, a date, a sort code, a quiz answer — as one cell per character, so the reader sees the shape before typing a character. One real <input> carries focus, value and the accessible name; the cells are decoration that mirrors it, and cell-input.js builds and paints them from the data-format you declare. The class is required; data-format declares the shape.
On a phone this matters: the single input keeps the OS caret, the soft keyboard and SMS autofill working where the reader is looking. Do not split it into one input per cell.
Anatomy
<div class="cell-input" data-format="6" data-mode="digits">
<label class="cell-input-cells" for="code" aria-hidden="true"></label>
<input type="text" id="code" aria-label="Verification code: 6 digits"
autocomplete="one-time-code" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
- — the block. Declares
data-formatand the optional axes below. - — a
<label>for the input,aria-hidden="true". The script builds groups of spans into it:data-cell="letter"cells take typed characters,data-cell="separator"cells show pre-revealed punctuation. Tapping any cell focuses the input through the label. - The
<input>— the only focusable, accessible control. It sits invisibly over the block, and its value is always the clean, clamped entry.
| Attribute | On | Values | What it does |
|---|---|---|---|
data-format |
block | "6", "3 4", "2-2-2", "2/2/4" |
Cell structure — see Format |
data-mode |
block | letters, digits, any (default) |
Filters input; digits sets the numeric keypad |
data-mask |
block | boolean | Cells render dots instead of characters |
data-size |
block | small |
Compact cells for long formats |
data-type |
block | success, danger |
Verdict colouring — see Verdicts |
data-static |
block | boolean | Display-only cells, no input, no JS |
The component owns --cell-input-* geometry and colour tokens; override those rather than the selectors.
Basic usage
<div class="cell-input" data-format="6" data-mode="digits">
<label class="cell-input-cells" for="code" aria-hidden="true"></label>
<input type="text" id="code" aria-label="Verification code: 6 digits"
autocomplete="one-time-code" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
Typing fills the cells left to right. While the input has focus, the next empty cell carries a 2px accent border — it is both the caret and the focus indicator, the same pattern platform code inputs use. Paste works and keeps every legal character: pasting 20-41-53 into a sort code drops the hyphens and lands six digits. On a 6-digit code, autocomplete="one-time-code" lets iOS offer the SMS code above the keyboard — a single real input makes that work; one input per cell breaks it.
Format
data-format describes the shape. Digits declare a run of typeable cells; everything else is structure:
| Character | Meaning |
|---|---|
6, 12, … |
That many typeable cells |
| space | Word break — words wrap as units |
- or / |
A shown separator cell, plus a wrap point |
anything else (', .) |
A shown separator inside the word |
Separators are pre-revealed punctuation: they are displayed, never typed, and the filter skips them on paste.
A two-word answer — the quiz's original use:
<div class="cell-input" data-format="6 4" data-mode="letters">…</div>
A date, day / month / year:
<div class="cell-input" data-format="2/2/4" data-mode="digits">…</div>
A bank sort code:
<div class="cell-input" data-format="2-2-2" data-mode="digits">…</div>
A licence key — long formats take data-size="small":
<div class="cell-input" data-format="4-4-4" data-size="small">…</div>
Cells uppercase their display with CSS; the stored value keeps the case that was typed. Say so near the field if the code is case-sensitive.
Masking
data-mask renders filled cells as dots. The masking is visual only — the real value stays in the input, and a screen reader echoes the real characters as they are typed. For a genuine secret, make the input type="password": the cells still paint, and assistive tech treats the value as protected. Reserve data-mask alone for shoulder-surfing theatre — a quiz answer, not a bank PIN.
<div class="cell-input" data-format="4" data-mode="digits" data-mask>
<label class="cell-input-cells" for="pin" aria-hidden="true"></label>
<input type="password" id="pin" aria-label="PIN: 4 digits" autocomplete="off" spellcheck="false">
</div>
Verdicts
After judging an entry, set data-type="success" or data-type="danger" on the block — letter cells take the status colours, separators stay quiet. on the block paints the danger state for live validation.
Colour is never the whole message. A verdict ships with a visible text outcome in a role="status" region, and danger sets aria-invalid="true" on the input — without those, the state change is invisible to a screen reader and ambiguous to colour-blind readers.
Correct — that's the answer.
Not this time — check the spelling and try again.
<div class="cell-input" data-format="6" data-mode="digits" data-type="danger">
<label class="cell-input-cells" for="code" aria-hidden="true"></label>
<input type="text" id="code" aria-label="Verification code: 6 digits" aria-invalid="true">
</div>
<p role="status">That code has expired — a new one is on its way.</p>
Static display
Cells with no input and no script — for showing a code rather than collecting one: a booking reference on a confirmation page, an example in documentation. Hand-author the cells with their characters, add data-static, and leave aria-hidden off so the code stays readable to everyone.
<div class="cell-input" data-static>
<span class="cell-input-cells"><span class="cell-input-word"><span class="cell-input-cell" data-cell="letter">b</span><span class="cell-input-cell" data-cell="letter">d</span><span class="cell-input-cell" data-cell="separator">-</span></span><span class="cell-input-word"><span class="cell-input-cell" data-cell="letter">7</span><span class="cell-input-cell" data-cell="letter">7</span><span class="cell-input-cell" data-cell="letter">2</span><span class="cell-input-cell" data-cell="letter">4</span></span></span>
</div>
This variant is HTML and CSS only — it works without cell-input.js.
JavaScript
Include assets/js/cell-input.js. It binds every .cell-input[data-format] that contains an input and a element, on page load and again after every Barba navigation; a data-cell-input-bound guard makes re-runs harmless. No GSAP, no dependencies.
The script builds the cells, enforces aria-hidden on the cells row, filters and clamps the value on every input event (accents folded, illegal characters dropped, pasted separators skipped), and dispatches two bubbling events on the block:
| Event | detail | When |
|---|---|---|
cell-input:change |
{ value, complete } |
The clean value changed |
cell-input:complete |
{ value } |
The final cell filled |
document.getElementById('otp-block').addEventListener('cell-input:complete', function (event) {
submitCode(event.detail.value);
});
Two contracts worth knowing:
- Format and wiring are read once at bind time. To change
data-formator swap the input afterwards, cleardata-cell-input-boundand callwindow.initCellInput(block.parentNode). - Setting
input.valuefrom script fires no event; dispatch aninputorchangeevent after it and the cells repaint.
A data-format with no typeable cells is an authoring error: the script leaves the block inert and names it in a console warning.
Accessibility
- The input must carry the accessible name. Write an
aria-label(oraria-labelledby) that names the purpose and the shape: "Verification code: 6 digits". The generated fallback describes shape only — do not ship it. - Keep the cells row
aria-hidden="true"whenever the block contains an input — the script enforces it. The cells are decoration; the input is the control. - Put a visible label or instruction above the block. The invisible name is for assistive tech, not a substitute for telling everyone what goes here.
- Verdict states must pair colour with a visible text outcome in a
role="status"region, and danger must setaria-invalid="true"on the input. - Real secrets must use
type="password";data-maskalone leaves the value exposed to character echo and password managers. - Static displays must not be
aria-hidden— the characters are the content. - Disabling means both
disabledon the input and on the block. - Keep the caret affordance — the 2px accent border on the next empty cell is the focus indicator. Override it and a keyboard user cannot see focus at all, because the input itself is invisible.
Usage rules
Do:
- Use it when the entry has a fixed, known shape — codes, PINs, dates, sort codes, quiz answers
- Match
data-formatto the shape the reader already knows — a sort code is2-2-2, never6 - Wire
cell-input:completefor auto-advance flows instead of watching keystrokes - Use
data-size="small"when a format runs past roughly 16 cells
Don't:
- Use it for free-form text — a name or an email is a plain input
- Build one input per cell — focus juggling breaks paste, autofill and screen readers
- Reveal a secret's length when the length itself is sensitive; use a plain
type="password"input instead - Apply
data-typewithout a text verdict alongside
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | What it controls |
|---|---|---|
2rem |
Letter cell width | |
2.5rem |
Cell height | |
1.25rem |
Separator cell width | |
| Gap between cells in a word | ||
| Gap between words | ||
| Cell fill | ||
| Cell border | ||
| Cell and ring radius |
Selectors
| Selector | Purpose |
|---|---|
| Block: positioning context for the overlaid input | |
.cell-input input |
The real control, stretched invisibly over the cells |
| The label row; tapping it focuses the input | |
| A word group; wraps as a unit | |
One cell; data-cell="letter" or data-cell="separator" |
|
.cell-input-cell.is-active |
The caret cell — the focus indicator, visible only while focused |
.cell-input-cell.is-filled |
A cell holding a character (JS hook, unstyled) |
.cell-input[data-size="small"] |
Re-points the geometry tokens to compact values |
.cell-input[data-type="success"] / [data-type="danger"] |
Verdict colours on letter cells |
.cell-input.is-error |
Paints the danger state |
.cell-input.is-disabled |
Disabled surface; pair with disabled on the input |
.cell-input[data-static] |
Display-only: default cursor, hand-authored cells |
Key rules
Overlay: the input is position: absolute; inset: 0; opacity: 0; pointer-events: none — the OS caret geometry lands on the cells, and the label forwards taps to the input.
Focus: the caret cell is the focus indicator — border-color: var(--input-focus) plus a 1px inset shadow for 2px of visual weight, gated on :focus-within. No ring on the group: the caret says "focused, and here". A forced-colors block outlines the row instead, since high-contrast mode erases the caret's colour.
Wrapping: row and word are both flex-wrap: wrap — words wrap as units, an over-long word wraps within itself.
Motion: cell border and background transition on --duration-xs / --ease-out, removed under prefers-reduced-motion: reduce.
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. Its behaviour ships as dist/js/cell-input.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/cell-input.js" defer></script>
In React, render this contract through the packaged adapter instead of writing the markup by hand:
import { CellInput } from '@bydefaultstudio/design-system/react';
The adapter renders the contract above and bridges this component's events to props — see React.