OTP field
A one-time code typed one character per cell. A paste fills every cell, and one hidden input carries the code to the form.
client - size not measured yet
OTP field
client - size not measured yetA one-time code typed one character per cell. A paste fills every cell, and one hidden input carries the code to the form.
Try it
length
size
invalid
groupSize
The code
otp-field.tsx
import { OTPField } from 'topclassui/otp-field'
<div className="grid justify-items-start gap-2">
<label htmlFor="otp-playground-code" className="text-sm text-body">Verification code</label>
<OTPField id="otp-playground-code" />
</div>The export is OTPField. In a form the hidden input sends what is typed so far, a partial code too.
Every state
6 statesImport from the package
topclassui/otp-field
import { OtpField } from 'topclassui/otp-field'Install once:npm i topclassui@beta zod
Examples
lengthis 6 by default, clamped to a whole number from 1 to 12.groupSizedraws a bar after every that many cells.- Digits are the default: ASCII, Arabic-Indic and Persian digits are taken and stored as ASCII, and spaces in a paste are dropped.
- Past eight items, cells and bars together, the row breaks at whole cells, so twelve cells stay 44px tall on a phone.
- Each cell is a square of the control scale, 40px at
md, and gives up width before height. Six cells need 184px. - A Field's control object spreads onto it as it is.
basic
Six digits from your authenticator app
basic.tsx
import { OTPField } from 'topclassui/otp-field'
/** Outside a Field: a native label points at the id of the field, which is the first cell, and a hint describes it. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-2">
<label htmlFor="otp-basic-code" className="text-sm text-body">Verification code</label>
<OTPField id="otp-basic-code" aria-describedby="otp-basic-hint" />
<p id="otp-basic-hint" className="text-sm text-muted">Six digits from your authenticator app</p>
</div>
)
}
grouped
grouped.tsx
import { OTPField } from 'topclassui/otp-field'
/** Six cells in two groups of three, with a bar between them that a screen reader does not read. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-2">
<label htmlFor="otp-grouped-code" className="text-sm text-body">Verification code</label>
<OTPField id="otp-grouped-code" groupSize={3} />
</div>
)
}
form
form.tsx
import { Button } from 'topclassui/button'
import { OTPField } from 'topclassui/otp-field'
/** In a plain form: it sends code= before anything is typed, and what is typed so far after, a partial code too. */
export default function Demo() {
return (
<form className="grid justify-items-start gap-3">
<label htmlFor="otp-form-code" className="text-sm text-body">Verification code</label>
<OTPField id="otp-form-code" name="code" />
<Button type="submit">Verify</Button>
</form>
)
}
sizes
Extra smallSmallMediumLargeExtra large
sizes.tsx
import { OTPField } from 'topclassui/otp-field'
/** The five sizes, each named by the text above it. A cell gives up width before it gives up height. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
<span id="otp-sizes-xs" className="text-sm text-body">Extra small</span>
<OTPField size="xs" aria-labelledby="otp-sizes-xs" defaultValue="482" />
<span id="otp-sizes-sm" className="text-sm text-body">Small</span>
<OTPField size="sm" aria-labelledby="otp-sizes-sm" defaultValue="482" />
<span id="otp-sizes-md" className="text-sm text-body">Medium</span>
<OTPField size="md" aria-labelledby="otp-sizes-md" defaultValue="482" />
<span id="otp-sizes-lg" className="text-sm text-body">Large</span>
<OTPField size="lg" aria-labelledby="otp-sizes-lg" defaultValue="482" />
<span id="otp-sizes-xl" className="text-sm text-body">Extra large</span>
<OTPField size="xl" aria-labelledby="otp-sizes-xl" defaultValue="482" />
</div>
)
}
long
long.tsx
import { OTPField } from 'topclassui/otp-field'
/** Twelve cells, the most a field takes. Past eight items the row wraps, so on a phone every cell keeps 44 px. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-2">
<label htmlFor="otp-long-code" className="text-sm text-body">Recovery code</label>
<OTPField id="otp-long-code" length={12} validationType="alphanumeric" />
</div>
)
}
In Arabic, right to left
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { OTPField } from 'topclassui/otp-field'
/** Arabic, inside TopClassProvider as the root layout would mount it. The group sits at the right, under its label,
* and its cells still run left to right, as the code reads. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<label htmlFor="otp-rtl-code" className="block text-sm text-body">رمز التحقق</label>
<OTPField id="otp-rtl-code" name="code" className="mt-2" />
</div>
</TopClassProvider>
)
}
Props
Keyboard and accessibility
- Name it with
id(for a<label htmlFor>) oraria-labelledby; the name lands on the first cell.aria-labelis refused. - Every later cell is named by its place, "Character 2 of 6", in the provider's language.
aria-describedbygoes on the first cell. invalidsets aria-invalid on every cell, never on the group: each line turns to danger and thickens.
Right to left
A code reads left to right, so on an Arabic page the cells still run left to right; the group sits at the inline start, beside its label.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.