formsclient

otp input

a row of little wells for a verification code. typing moves you along, and pasting the whole code fills them all.

we sent it to ada@example.com. (123456 works)

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add otp-input

or by hand: drop these files into components/ui and import from there. otp input leans on FieldMessage.tsx, so those come along too.

components/ui/OtpInput.tsxtsx
"use client";
import React, { useId, useRef, useState } from "react";
import { FieldLabel, FieldMessage } from "./FieldMessage";
interface OtpInputProps {
/** How many characters */
length?: number;
value?: string;
defaultValue?: string;
onChange?: (code: string) => void;
/** Fires once every cell is filled, with the whole code */
onComplete?: (code: string) => void;
/** Digits only, or letters and digits */
type?: "numeric" | "alphanumeric";
/** Split the cells into wells of this many, like 123 456 */
groupSize?: number;
/** Show dots instead of the characters */
mask?: boolean;
label?: string;
hint?: string;
error?: string;
disabled?: boolean;
autoFocus?: boolean;
name?: string;
}
const CELL = 44;
/* One real input sits underneath, so typing, backspace, paste and the
phone filling the code from a text all behave natively. What you see
is drawn over it: each group is a single sunken field, cut into cells
by scored grooves, that lifts flush and draws its border while you're
in it, exactly like Input. A caret glides from cell to cell on a
spring as you type, and characters press into place. */
export function OtpInput({
length = 6,
value,
defaultValue = "",
onChange,
onComplete,
type = "numeric",
groupSize,
mask = false,
label,
hint,
error,
disabled = false,
autoFocus = false,
name,
}: OtpInputProps) {
const uid = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [internal, setInternal] = useState(defaultValue.slice(0, length));
const code = (value !== undefined ? value : internal).slice(0, length);
const [focused, setFocused] = useState(false);
// The cell the caret is in: the selection start, kept within the code
const [cursor, setCursor] = useState(0);
const messageId = error || hint ? `${uid}-message` : undefined;
const at = Math.min(cursor, code.length, length - 1);
const pattern = type === "numeric" ? /[^0-9]/g : /[^0-9a-z]/gi;
/* A filled cell is selected rather than sitting behind a caret, so
typing over it replaces it in place */
const selectCell = (i: number) => {
const el = inputRef.current;
if (!el) return;
const n = el.value.length;
const pos = Math.max(0, Math.min(i, n));
el.setSelectionRange(pos, pos < n ? pos + 1 : pos);
setCursor(pos);
};
const groups = groupSize && groupSize < length ? Math.ceil(length / groupSize) : 1;
const per = groups > 1 ? groupSize! : length;
return (
<div className="group/field flex flex-col gap-1.5">
{label && (
<FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>
{label}
</FieldLabel>
)}
<div
className={["relative flex w-fit items-center gap-3", disabled ? "opacity-40" : ""].join(" ")}
>
<input
ref={inputRef}
id={`${uid}-input`}
type={mask ? "password" : "text"}
inputMode={type === "numeric" ? "numeric" : "text"}
autoComplete="one-time-code"
autoCapitalize="characters"
autoFocus={autoFocus}
spellCheck={false}
maxLength={length}
disabled={disabled}
value={code}
aria-invalid={error ? true : undefined}
aria-describedby={messageId}
aria-label={label ? undefined : `${length}-character code`}
// The input lies over the cells; send the caret to whichever was clicked
onClick={(e) => {
const cell = document
.elementsFromPoint(e.clientX, e.clientY)
.find((el): el is HTMLElement => el instanceof HTMLElement && el.dataset.cell !== undefined);
selectCell(cell ? Number(cell.dataset.cell) : code.length);
}}
onFocus={() => {
setFocused(true);
requestAnimationFrame(() => selectCell(code.length));
}}
onBlur={() => setFocused(false)}
onChange={(e) => {
const next = e.target.value.replace(pattern, "").toUpperCase().slice(0, length);
setInternal(next);
onChange?.(next);
if (next.length === length && next !== code) onComplete?.(next);
const pos = Math.min(e.target.selectionStart ?? next.length, next.length);
requestAnimationFrame(() => selectCell(pos));
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
selectCell(at + (e.key === "ArrowLeft" ? -1 : 1));
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
selectCell(e.key === "Home" ? 0 : code.length);
}
}}
onSelect={(e) => {
const el = e.currentTarget;
setCursor(Math.min(el.selectionStart ?? 0, length - 1));
}}
// Invisible, but it's what a tap lands on, so phones raise their keyboard
className="absolute inset-0 z-30 h-full w-full cursor-text opacity-0 disabled:cursor-not-allowed"
style={{ fontSize: 16 /* stops iOS zooming in on focus */ }}
/>
{Array.from({ length: groups }, (_, g) => {
const start = g * per;
const cells = Math.min(per, length - start);
const caretHere = focused && at >= start && at < start + cells;
return (
<React.Fragment key={g}>
{g > 0 && (
<span
aria-hidden
className="h-[2px] w-2.5 shrink-0 rounded-full"
style={{ background: "var(--groove)", boxShadow: "var(--groove-lip-below)" }}
/>
)}
{/* One field per group: data-open gives it the focused well */}
<div
aria-hidden
data-open={caretHere || undefined}
data-invalid={error ? true : undefined}
className="field relative z-10 flex h-[52px] overflow-hidden rounded-xl"
>
{Array.from({ length: cells }, (_, k) => {
const i = start + k;
const ch = code[i];
return (
<div key={i} data-cell={i} className="relative grid place-items-center" style={{ width: CELL }}>
{k > 0 && (
<span
className="absolute inset-y-3 left-0 w-px"
style={{ background: "var(--groove)", boxShadow: "var(--groove-lip)" }}
/>
)}
{ch &&
(mask ? (
<span key={ch + i} className="animate-checkbox-in h-2.5 w-2.5 rounded-full bg-ink" />
) : (
<span key={ch + i} className="animate-checkbox-in text-[20px] font-semibold tabular-nums text-ink">
{ch}
</span>
))}
{/* A filled cell under the caret is picked out, ready to be typed over */}
{caretHere && i === at && ch && (
<span className="absolute inset-x-1.5 bottom-2 h-[2px] rounded-full bg-ink animate-otp-caret" />
)}
</div>
);
})}
{/* The caret glides between the empty cells of this group */}
{caretHere && !code[at] && (
<span
className="pointer-events-none absolute top-1/2 h-[22px] w-[2px] -translate-y-1/2"
style={{
left: (at - start) * CELL + CELL / 2 - 1,
transition: "left 260ms var(--ease-spring)",
}}
>
<span className="block h-full w-full animate-otp-caret rounded-full bg-ink" />
</span>
)}
{/* Input's sweep of light as the well comes up */}
{caretHere && (
<span
className="pointer-events-none absolute inset-y-0 -left-[30%] block w-[30%] animate-input-shimmer"
style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}
/>
)}
</div>
</React.Fragment>
);
})}
</div>
{name && <input type="hidden" name={name} value={code} />}
<FieldMessage id={messageId} error={error} hint={hint} />
</div>
);
}
components/ui/FieldMessage.tsxtsx
"use client";
import React from "react";
/* The label above a field and the line under it, shared by every form
control so they read as one family. */
export function FieldLabel({
id,
htmlFor,
children,
}: {
id?: string;
htmlFor?: string;
children: React.ReactNode;
}) {
const className =
"text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
return htmlFor ? (
<label id={id} htmlFor={htmlFor} className={className}>
{children}
</label>
) : (
<span id={id} className={className}>
{children}
</span>
);
}
/* An error wins over a hint; either is announced through `id` */
export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
if (error) {
return (
<p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
</svg>
{error}
</p>
);
}
if (hint) {
return (
<p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
{hint}
</p>
);
}
return null;
}
/* A small raised key that sits inside a field or a panel: steppers,
month arrows, clear buttons. It sinks flush while held. */
export function FieldKey({
label,
disabled,
onClick,
onPress,
onRelease,
focusable = true,
size = 28,
children,
}: {
label: string;
disabled?: boolean;
onClick?: () => void;
/** Pointer went down (for hold-to-repeat) */
onPress?: () => void;
onRelease?: () => void;
focusable?: boolean;
size?: number;
children: React.ReactNode;
}) {
const [pressed, setPressed] = React.useState(false);
const release = () => {
setPressed(false);
onRelease?.();
};
return (
<button
type="button"
aria-label={label}
tabIndex={focusable ? undefined : -1}
disabled={disabled}
onClick={onClick}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (!focusable) e.preventDefault(); // keep focus in the field
setPressed(true);
onPress?.();
}}
onPointerUp={release}
onPointerLeave={() => pressed && release()}
onPointerCancel={release}
className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"
style={{
width: size,
height: size,
background: "var(--face)",
boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
}}
>
<svg
aria-hidden
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
style={{
transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
transition: "transform 150ms var(--ease-spring)",
}}
>
{children}
</svg>
</button>
);
}

usage

tsx
import { OtpInput } from "@blurrow/ui";
tsx
<OtpInput length={6} onComplete={verify} />

examples

letters and masking

props

straight from the source, so they're always up to date.

propsts
interface OtpInputProps {
/** How many characters */
length?: number;
value?: string;
defaultValue?: string;
onChange?: (code: string) => void;
/** Fires once every cell is filled, with the whole code */
onComplete?: (code: string) => void;
/** Digits only, or letters and digits */
type?: "numeric" | "alphanumeric";
/** Split the cells into wells of this many, like 123 456 */
groupSize?: number;
/** Show dots instead of the characters */
mask?: boolean;
label?: string;
hint?: string;
error?: string;
disabled?: boolean;
autoFocus?: boolean;
name?: string;
}