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-inputor 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
1"use client";2
3import React, { useId, useRef, useState } from "react";4import { FieldLabel, FieldMessage } from "./FieldMessage";5
6interface OtpInputProps {7 /** How many characters */8 length?: number;9 value?: string;10 defaultValue?: string;11 onChange?: (code: string) => void;12 /** Fires once every cell is filled, with the whole code */13 onComplete?: (code: string) => void;14 /** Digits only, or letters and digits */15 type?: "numeric" | "alphanumeric";16 /** Split the cells into wells of this many, like 123 456 */17 groupSize?: number;18 /** Show dots instead of the characters */19 mask?: boolean;20 label?: string;21 hint?: string;22 error?: string;23 disabled?: boolean;24 autoFocus?: boolean;25 name?: string;26}27
28const CELL = 44;29
30/* One real input sits underneath, so typing, backspace, paste and the31 phone filling the code from a text all behave natively. What you see32 is drawn over it: each group is a single sunken field, cut into cells33 by scored grooves, that lifts flush and draws its border while you're34 in it, exactly like Input. A caret glides from cell to cell on a35 spring as you type, and characters press into place. */36export function OtpInput({37 length = 6,38 value,39 defaultValue = "",40 onChange,41 onComplete,42 type = "numeric",43 groupSize,44 mask = false,45 label,46 hint,47 error,48 disabled = false,49 autoFocus = false,50 name,51}: OtpInputProps) {52 const uid = useId();53 const inputRef = useRef<HTMLInputElement>(null);54 const [internal, setInternal] = useState(defaultValue.slice(0, length));55 const code = (value !== undefined ? value : internal).slice(0, length);56 const [focused, setFocused] = useState(false);57 // The cell the caret is in: the selection start, kept within the code58 const [cursor, setCursor] = useState(0);59 const messageId = error || hint ? `${uid}-message` : undefined;60 const at = Math.min(cursor, code.length, length - 1);61
62 const pattern = type === "numeric" ? /[^0-9]/g : /[^0-9a-z]/gi;63
64 /* A filled cell is selected rather than sitting behind a caret, so65 typing over it replaces it in place */66 const selectCell = (i: number) => {67 const el = inputRef.current;68 if (!el) return;69 const n = el.value.length;70 const pos = Math.max(0, Math.min(i, n));71 el.setSelectionRange(pos, pos < n ? pos + 1 : pos);72 setCursor(pos);73 };74
75 const groups = groupSize && groupSize < length ? Math.ceil(length / groupSize) : 1;76 const per = groups > 1 ? groupSize! : length;77
78 return (79 <div className="group/field flex flex-col gap-1.5">80 {label && (81 <FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>82 {label}83 </FieldLabel>84 )}85
86 <div87 className={["relative flex w-fit items-center gap-3", disabled ? "opacity-40" : ""].join(" ")}88 >89 <input90 ref={inputRef}91 id={`${uid}-input`}92 type={mask ? "password" : "text"}93 inputMode={type === "numeric" ? "numeric" : "text"}94 autoComplete="one-time-code"95 autoCapitalize="characters"96 autoFocus={autoFocus}97 spellCheck={false}98 maxLength={length}99 disabled={disabled}100 value={code}101 aria-invalid={error ? true : undefined}102 aria-describedby={messageId}103 aria-label={label ? undefined : `${length}-character code`}104 // The input lies over the cells; send the caret to whichever was clicked105 onClick={(e) => {106 const cell = document107 .elementsFromPoint(e.clientX, e.clientY)108 .find((el): el is HTMLElement => el instanceof HTMLElement && el.dataset.cell !== undefined);109 selectCell(cell ? Number(cell.dataset.cell) : code.length);110 }}111 onFocus={() => {112 setFocused(true);113 requestAnimationFrame(() => selectCell(code.length));114 }}115 onBlur={() => setFocused(false)}116 onChange={(e) => {117 const next = e.target.value.replace(pattern, "").toUpperCase().slice(0, length);118 setInternal(next);119 onChange?.(next);120 if (next.length === length && next !== code) onComplete?.(next);121 const pos = Math.min(e.target.selectionStart ?? next.length, next.length);122 requestAnimationFrame(() => selectCell(pos));123 }}124 onKeyDown={(e) => {125 if (e.key === "ArrowLeft" || e.key === "ArrowRight") {126 e.preventDefault();127 selectCell(at + (e.key === "ArrowLeft" ? -1 : 1));128 } else if (e.key === "Home" || e.key === "End") {129 e.preventDefault();130 selectCell(e.key === "Home" ? 0 : code.length);131 }132 }}133 onSelect={(e) => {134 const el = e.currentTarget;135 setCursor(Math.min(el.selectionStart ?? 0, length - 1));136 }}137 // Invisible, but it's what a tap lands on, so phones raise their keyboard138 className="absolute inset-0 z-30 h-full w-full cursor-text opacity-0 disabled:cursor-not-allowed"139 style={{ fontSize: 16 /* stops iOS zooming in on focus */ }}140 />141
142 {Array.from({ length: groups }, (_, g) => {143 const start = g * per;144 const cells = Math.min(per, length - start);145 const caretHere = focused && at >= start && at < start + cells;146 return (147 <React.Fragment key={g}>148 {g > 0 && (149 <span150 aria-hidden151 className="h-[2px] w-2.5 shrink-0 rounded-full"152 style={{ background: "var(--groove)", boxShadow: "var(--groove-lip-below)" }}153 />154 )}155 {/* One field per group: data-open gives it the focused well */}156 <div157 aria-hidden158 data-open={caretHere || undefined}159 data-invalid={error ? true : undefined}160 className="field relative z-10 flex h-[52px] overflow-hidden rounded-xl"161 >162 {Array.from({ length: cells }, (_, k) => {163 const i = start + k;164 const ch = code[i];165 return (166 <div key={i} data-cell={i} className="relative grid place-items-center" style={{ width: CELL }}>167 {k > 0 && (168 <span169 className="absolute inset-y-3 left-0 w-px"170 style={{ background: "var(--groove)", boxShadow: "var(--groove-lip)" }}171 />172 )}173 {ch &&174 (mask ? (175 <span key={ch + i} className="animate-checkbox-in h-2.5 w-2.5 rounded-full bg-ink" />176 ) : (177 <span key={ch + i} className="animate-checkbox-in text-[20px] font-semibold tabular-nums text-ink">178 {ch}179 </span>180 ))}181 {/* A filled cell under the caret is picked out, ready to be typed over */}182 {caretHere && i === at && ch && (183 <span className="absolute inset-x-1.5 bottom-2 h-[2px] rounded-full bg-ink animate-otp-caret" />184 )}185 </div>186 );187 })}188
189 {/* The caret glides between the empty cells of this group */}190 {caretHere && !code[at] && (191 <span192 className="pointer-events-none absolute top-1/2 h-[22px] w-[2px] -translate-y-1/2"193 style={{194 left: (at - start) * CELL + CELL / 2 - 1,195 transition: "left 260ms var(--ease-spring)",196 }}197 >198 <span className="block h-full w-full animate-otp-caret rounded-full bg-ink" />199 </span>200 )}201
202 {/* Input's sweep of light as the well comes up */}203 {caretHere && (204 <span205 className="pointer-events-none absolute inset-y-0 -left-[30%] block w-[30%] animate-input-shimmer"206 style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}207 />208 )}209 </div>210 </React.Fragment>211 );212 })}213 </div>214
215 {name && <input type="hidden" name={name} value={code} />}216 <FieldMessage id={messageId} error={error} hint={hint} />217 </div>218 );219}components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 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"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}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;}