Input OTP
A field for a one-time code, in boxes.
src/components/rahti_ui/input_otp.rs
Basics
Type into either one. The caret is the browser's, the selection is the browser's, `Ctrl+A` works, paste works, and on a phone the keypad comes up numeric and the SMS code is offered above it — because this is one field with `autocomplete="one-time-code"` on it rather than six inputs pretending to be one. The boxes are a background image; the characters are placed by `letter-spacing`.
Slots
One number reaches four places: the field's width, the `maxlength`, the `pattern` the browser validates against, and the painting area of the dividers — which is one cell short of the field, so the last divider lands before the last box rather than on the border. Nothing in the class list is a literal; every number is derived from `--otp-cell` and `--otp-slots`.
Cell size
`--otp-cell` is the width of one box, and the letter-spacing is `--otp-cell - 1ch` — so the characters move with the boxes rather than being positioned separately. `1ch` is the advance of a zero, which is every glyph's advance in a monospace face; that is why `font-mono` is load-bearing here rather than decorative, and it is the one visual delta from shadcn.
States
The `aria-invalid:` utilities are shadcn's, taken off the slot and put on the field — the styling and the accessible state are the same fact, spelled once. The third field is the escape hatch for an alphanumeric code: `inputmode` becomes `text` and the digits-only `pattern` is removed rather than left somewhere to reject what the user types.
Groups and a separator
shadcn's canonical example is three slots, a dash, three slots — and inside one field that is not possible here, because the characters are placed by one uniform advance and a gap in the middle would put every character after it in the wrong box. So the grouping is two fields with a separator between them, which is what `InputOTPGroup` and `InputOTPSeparator` are for. shadcn's container and its group collapse into the one element, since there are no slot elements left to group.
Two fields, so two things an autofill has to choose between — it will fill the first and leave the second empty. That is the trade, and it is why the single field above is the one to reach for unless the grouping matters more than the code arriving by itself.
As a form posts it
One `name`, one value — `?code=428913`. Submit it empty and the browser refuses; submit it with four digits and it refuses too, because `pattern` asks for six. Neither of those is JavaScript, and neither is six hidden fields being stitched together on the way out.
Attributes
`.attr(…)` writes anything; `.unset(…)` takes one away. Three attributes are written by default rather than left to the call site — `autocomplete="one-time-code"`, `inputmode` and `pattern` — because they are the reason to prefer one input over six, and a component that made you remember them would be giving the argument away.
Class override
`class` is merged, not appended, and it lands on the box rather than on the input — that is the element an override has anything to change. It costs more here than on most components, though: the boxes *are* the class list, so until PulsePoint mounts these two are unstyled boxes rather than rows of slots. The first shows a font size reaching the characters through the box, and the second is a reminder that `rounded-full` rounds the field and not the boxes — the dividers are a background and have no corners of their own.
What this port changed
shadcn's is the `input-otp` package: one transparent input, a `<div>` per character, a React context handing each slot its char and active state, and an animated element standing in for the caret the real input is hiding. This is one `<input>`, in a box with the slots drawn on it.
| shadcn | here | why |
|---|---|---|
| InputOTPSlot × 6 | repeating-linear-gradient | Transparent for a cell, then a pixel of `var(--input)`, painted over an area one cell short of the field so the last divider lands before the last box. The outer rounded border is the box's own. |
| — (each slot centres its char) | letter-spacing + font-mono | One advance per cell: `--otp-cell - 1ch`, with half of that as a `text-indent` to centre the first. Exact for any fixed-advance face, wrong for a proportional one — which is why `font-mono` is not decoration. |
| animate-caret-blink | the browser's caret | There is nothing hiding the real one, so there is nothing to stand in for it. |
| data-[active=true]:ring-[3px] | has-focus-visible:ring-[3px] | shadcn rings the slot the caret is in. There is one field and one caret here, so there is one ring — the same one every other field in this library has. |
| — (each slot is its own box) | overflow-clip on the box | `letter-spacing` follows the last character too, so the text is half a cell wider than the boxes. The input is that width — so it never scrolls to find its caret and never drags the characters out of their boxes — and the box clips what sticks out. `clip` rather than `hidden`: `hidden` scrolls, and the bug just moves up an element. |
| — (a hidden input underneath) | autocomplete="one-time-code" | The whole argument. A phone offers the code from an SMS into one field; six inputs get the first digit and drop five. The package works hard to keep a single real input underneath — here it is the control. |
What is not ported
`InputOTPSlot`. There are no slot elements to hand a `className` to, so there is no per-slot override and no per-slot ring. And a separator *inside* one field: the characters are placed by one uniform advance, so a gap in the middle would put every character after it in the wrong box.
Both are available by composing two fields, which the grouped example above shows — and which costs the autofill. That is a real trade rather than a workaround, and the single field is the one to reach for unless the grouping matters more.
The component tag, and its boundary
The tag is the right call for a field this page does not drive — a `name`, a `slots`, a `required`. What a plain form posts, the tag renders.
Where the tag stops
A binding does not cross it: PulsePoint owns `value` and the events that maintain it, and takes that ownership in the scope the element is mounted in, which through a tag is the component's.
// Compiles against a scope with no `code`, and does nothing.
<InputOTP value={code} oninput={setCode(target.value)} />A field the page drives wants `input_otp().…render()` interpolated into the page's own block, which is what the section below this one does.
Controlled, page scope
`input` fires on every character — including the six an autofill puts in at once, which is the event to listen to if a form should submit itself when the code is complete.
{code.length} of 6 — {code || "empty"}