Skip to content

PlOtpField

A row of one-character slots: a PIN, a texted verification code, an invite key. One value behind however many boxes, with paste, backspace and the phone's own autofill all doing what a reader expects.

React
tsx
import { PlOtpField } from 'plass-ui';

<PlOtpField label="Verification code" groupSize={3} onComplete={verify} />;
dart
import 'package:plass_ui/plass_ui.dart';

PlOtpField(
  label: const Text('Verification code'),
  groupSize: 3,
  onCompleted: verify,
);

Props

PropTypeDefaultDescription
variantshared'solid' | 'glass' | 'ghost''glass'What a slot is made of. solid is the well rather than a tinted pane, for the reason it is on PlTextField
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'The slot's box and the type inside it — the slot's own ladder rather than the control one
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Changes the gap between slots and nothing else
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
lengthnumber6How many characters the code has. Clamped to 2–12
charset'numeric' | 'alpha' | 'alphanumeric' | 'any''numeric'What may be typed. Anything rejected is dropped and reported through onValueInvalid
maskbooleanfalseHides the characters, the way a password field does
groupSizenumberSplits the row with a separator every this many slots
separatorReactNode'–'What is drawn between two groups
valuestringThe code. Use with onValueChange for a controlled field
defaultValuestringWhat it starts as, for an uncontrolled one
onValueChange(value: string) => voidCalled with the new code
onComplete(value: string) => voidFires once every slot is filled — the moment to verify the code
onValueInvalid(value: string) => voidFires when typed or pasted text held characters the charset rejects
autoSubmitbooleanfalseSubmits the owning form as soon as the code is complete
label · description · errorReactNodeLabel above the row, helper text and error message below it
invalidbooleanForces the invalid state without a message. Defaults to whether error has content
namestringIdentifies the field when a form is submitted
requiredbooleanfalseThe form must have a complete code before it submits
disabledbooleanfalseEvery slot stops answering
readOnlybooleanfalseReadable and copyable, but not typeable
autoFocusbooleanfalsePuts the caret in the first slot on mount
hotKeysRecord<string, () => void>Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed**
classNames{ label?, control?, description?, error?: string }Classes on the parts a className does not reach. control is the part a reader acts on
PropTypeDefaultDescription
controllerTextEditingController?The code being typed. Left out, the field owns a controller of its own
onChangedValueChanged<String>?Called with the new code
onCompletedValueChanged<String>?Fires once every slot is filled — the moment to verify the code
onRejectedValueChanged<String>?Fires with the characters the charset rejected
variantsharedPlassVariantPlassVariant.glassWhat a slot is made of. solid is the well rather than a tinted pane, for the reason it is on PlTextField
sizesharedPlassSizePlassSize.mdThe slot's box and the type inside it — the slot's own ladder rather than the control one
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardChanges the gap between slots and nothing else
elevationsharedint0Drop shadow depth. 0 means no shadow at all
lengthint6How many characters the code has. Clamped to 2–12
charsetPlOtpCharsetPlOtpCharset.numericWhat may be typed. Anything rejected is dropped and reported through onValueInvalid
maskboolfalseHides the characters, the way a password field does
groupSizeint?Splits the row with a separator every this many slots
separatorString'–'What is drawn between two groups
label · description · errorWidget?Label above the row, helper text and error message below it
invalidbool?Forces the invalid state without a message. Defaults to whether error has content
disabledboolfalseEvery slot stops answering
readOnlyboolfalseReadable and copyable, but not typeable
semanticLabelString?The name a screen reader announces for the row
focusNode · autofocusFocusNode? · boolDrive focus from outside, or put the caret in the row on insertion
hotKeysPlassHotKeys?Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed**

Every native <div> attribute passes straight through, onto the row of slots rather than onto the field around it. color is excluded because it is a Plass prop here, onChange because the component spells it onValueChange, and children because the slots are the children.

A className lands on the stack that holds the label, the control and the two lines under it. classNames reaches the four parts inside it: label, control (the row of slots) description and error.

The value lives in a TextEditingController, the way it does on a PlTextField, so value and defaultValue have one parameter between them, and a caller who wants to clear the code sets controller.text.

What the shared axes (variant size color density elevation) mean across the library is in prop conventions.

Examples

length

Clamped to 2–12. A single box is a PlTextField, and past twelve the row stops fitting a phone, which is the device most of these codes are typed on.

React

charset

What may be typed. Anything rejected is dropped rather than shown, and onValueInvalid reports it, a slot that silently swallows a keystroke is a slot the reader thinks is broken.

numeric is the default because that is what a texted code is, and it is also what puts a number pad in front of a phone.

React

groupSize and separator

groupSize={3} on a six-character code gives the familiar two blocks of three. The separator is punctuation inside one value rather than a break between two things, so it is hidden from a screen reader entirely: a reader that announced it once per group would be reading out the shape of the box instead of the code in it.

variant

The same field shell as PlTextField and PlSelect, because a slot is a field-shaped box and a form holding both should not look like two form kits stacked on each other. solid is the well, the glass at its most opaque with a shadow falling into it, and not a tinted pane, for the reason it is on a text field: a caret and a selection have to stay legible on top of it.

React

size

A slot has its own ladder rather than the control one, for the reason a tick box does: it is a character standing on its own rather than a control in a row of controls, and an md slot the height of an md PlButton would be too small to read a code out of across a desk. Every step is taller than it is wide, which is what makes a row of them read as places for one character each.

The type scale is two steps up the control ladder with it. A verification code is read off one phone and typed with the other hand; it is the one piece of text in a form that should be bigger than the label above it.

density touches the gap between slots and nothing else.

React

mask, readOnly, disabled and error

error carries a message and turns the field invalid, which re-points the whole slot family at danger so the edge, the ring, the caret and the message all turn over together. invalid is the escape hatch for a form library that owns the validity.

React

Composition

One <input> per slot, with Base UI keeping a single value behind them. That is what a browser's paste and autofill expect, and it is what makes a click land on the first empty slot rather than on the box under the pointer.

One editor behind the whole row, drawn as slots. Flutter's text input is a single connection to the platform, and splitting it into six would be six keyboards fighting over one code, so the value lives in a TextEditingController, the boxes are painted from it, and pressing anywhere in the row puts the caret at the first empty slot.

The editor is laid out over the row at zero opacity rather than taken off screen: a text input has to be in the tree and measured to hold that connection, so it cannot be Offstage. Nothing touches it directly, one gesture owns every press, and what a reader sees is the boxes.

Rejected characters go through a formatter of the component's own rather than Flutter's FilteringTextInputFormatter, which drops them and says nothing. A refusal that disappears silently is the single worst thing a code field does: the reader presses a key, sees nothing, and concludes the field is broken.

Accessibility

  • Built on Base UI's OTP Field, which owns everything that makes this harder than it looks: one hidden value behind however many inputs, paste spread across the slots from wherever the caret was, backspace stepping back a box, and a click landing on the first empty slot rather than on the one under the pointer.
  • Every slot carries autocomplete="one-time-code", so a phone offers the code straight from the message.
  • The label, the description and the error are wired to the row by Base UI's Field, one for, one aria-describedby, and no ids for a caller to keep in step.
  • The separator is an aria-hidden <span> rather than a role="separator". It is punctuation inside one value, not a break between two things.
  • The focus ring on a slot is :focus rather than :focus-visible, which is the one place in the library that distinction is deliberately dropped: a slot is put in focus by clicking it as often as by typing into it, and the ring is the only thing saying which character the next keystroke lands on.
  • The row is one text-field semantics node carrying the code as its value. The boxes are a drawing of that value and are excluded from semantics entirely, so a screen reader reads the code rather than counting empty rectangles.
  • The editor carries AutofillHints.oneTimeCode, so a phone offers the code straight from the message.
  • The ring is drawn on the slot the next keystroke lands in, and it follows focus rather than focus-visible, for the reason it does in the other package.

Differences from the React build

ReactFlutterWhy
one <input> per slotone editor behind the rowFlutter's text input is a single connection to the platform. Six of them would be six keyboards fighting over one code.
value / defaultValue / onValueChangecontroller / onChangedThe shape every editable widget in Flutter has, and the one PlTextField already uses.
onValueInvalidonRejectedIt is handed the characters that were refused rather than the value that survived, which is the more useful half.
name, required, autoSubmitAll three are about an HTML form submission, which Flutter has no equivalent of.
autoFocusautofocusFlutter's spelling.
className, styleThere is no class list and no style attribute to pass through.

Released under the MIT License