Input OTP
August 2026
Six boxes in two groups of three, so the eye reads a code in halves, with a small pill between them to rest on. Each digit rises into its box and sharpens as it lands, which reads as arriving rather than appearing. The difference is slight on a single digit and plain across six typed in a row.
The active box is marked by an inset ring rather than a darker border, so the whole field does not shift colour every time the caret moves along. The caret itself stays visible for most of its cycle: a steady mark reads as a caret, where an even fade reads as a glow.
input-otp.tsx
"use client"
import { motion, useReducedMotion } from "motion/react"
import { ENTER } from "@/components/site/motion"
import { OTPInput, type SlotProps } from "input-otp"
import { useState } from "react"
/**
* A six box one-time-code field, rebuilt to match a reference implementation
* exactly. Every number below was measured on the rendered reference, not
* chosen: geometry read from the box model, animation captured by trapping
* `Element.prototype.animate` and reading the keyframes it received.
*
* WHAT THE MEASUREMENT CHANGED, against the previous version of this file:
*
* - boxes went from 44x40 to 40x36, and the radius from 8px to 10px
* - the gap became two values, 4px inside a group of three and 8px around the
* separator, where a single 6px gap ran across all six before
* - a separator appeared: an 8x2 pill between the third and fourth box
* - the active box is marked by an inset ring rather than a darker border, so
* the boxes never shift colour and nothing reflows
* - the caret keyframes are the reference ones, held at each end rather than
* crossing linearly: visible, out at 20%, still out at 50%, back at 70%
* - the digit animation lost its spring, its travel and its blur
*
* THAT LAST ONE IS THE REAL LESSON. The previous version faded, slid 8px and
* unblurred 4px on a 350ms spring. The reference does one thing: opacity 0.2
* to 1 over 90ms, ease-out. Trapped at the source, that single call is all a
* keystroke produces. Four times shorter and a quarter of the properties, and
* it reads as faster rather than plainer, because a digit that arrives while
* the eye is still moving does not need to announce itself.
*/
/**
* THE CARET BLINK, and it is the only motion left in this file.
*
* Everything about the digit arriving now comes from `ENTER` in the site's
* motion system: how far it travels, how blurred it starts, on what curves. The
* numbers were measured here, then moved there, because a second thing that
* appears would otherwise have them retyped by hand and the two would drift
* apart at the first adjustment.
*
* The blink stays local on purpose. The motion system names a role by HOW an
* object moves, never by which component uses it, and a blinking caret is not a
* way of moving: it is one affordance of one kind of field. Promoting it would
* put a component's name in a system that refuses component names.
*
* Held at both ends rather than fading evenly: visible, gone by 20%, still gone
* at 50%, back at 70%, then held. A plain sine reads as a glow; this reads as a
* caret, because the eye gets a steady mark for most of the cycle.
*/
const BLINK = {
opacity: [1, 0, 0, 1, 1],
transition: {
duration: 1,
times: [0, 0.2, 0.5, 0.7, 1],
repeat: Number.POSITIVE_INFINITY,
ease: "linear" as const,
},
}
function Slot({ char, isActive, reduced }: SlotProps & { reduced: boolean }) {
return (
<div
// 10px is off the project radius scale, which offers 8 and 12. It is the
// reference value and it is kept as a literal for the same reason the
// focus ring of an inline link is 2px: reproducing a reference beats
// rounding to the nearest token when the difference is visible.
//
// THREE COLOURS, THREE JOBS, and the digit is not the one the container
// suggests. Reading the computed colour of this box gives the muted grey,
// yet the reference renders its digits in pure black: only a screenshot
// showed it. The digit is `gray-1200` (16.29:1), the caret `gray-1000`
// (4.74:1), the border `gray-700`.
//
// THE BORDER IS BELOW THRESHOLD AND STAYS. At 1.57:1 against white it
// sits under the 3:1 that WCAG 1.4.11 asks of a boundary carrying its own
// information, and here the border is the only thing saying how many
// boxes the field expects. It is kept because the design wants that grey,
// which is a decision and not an oversight.
//
// `contrast-more:` is what keeps that from being a hole. A reader who has
// asked their system for more contrast gets `gray-900`, measured at
// 3.32:1, which clears the threshold. Everyone else sees the grey it was
// drawn with. Nobody gets a worse page than before, and the people the
// rule exists for get the one the rule asks for.
className="group relative flex h-10 w-9 items-center justify-center rounded-[10px] border border-gray-700 font-medium text-base text-gray-1200 contrast-more:border-gray-900"
>
{char !== null && (
// THE CLIP WINDOW IS THE DIGIT, NOT THE BOX, and that is the whole
// effect. This wrapper takes no width or height, so it shrinks to the
// glyph it holds: 9x24 rather than the 34x40 of the box around it.
// Travelling 20px through a 24px window means the digit is revealed a
// slice at a time, climbing out from behind its own baseline. Size this
// wrapper to the box instead and the digit is visible for almost the
// whole flight, which reads as a plain slide and loses the depth.
//
// The reference writes `size-[inherit]` here, which its build drops on
// the floor. Ours honoured it, sized the window to the box, and killed
// the effect. Leaving the classes off states the intent that the dead
// class only happened to express.
<div className="relative flex items-center justify-center overflow-hidden">
<motion.span
animate={ENTER.to}
initial={reduced ? ENTER.to : ENTER.from}
key={char}
transition={reduced ? { duration: 0 } : ENTER.transition}
>
{char}
</motion.span>
</div>
)}
{char === null && isActive && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{/* Under reduced motion the caret is a steady bar. It still marks the
active box, and the CSS `prefers-reduced-motion` block of a host
project cannot reach it: motion writes through WAAPI, not CSS
animations, so the guard has to live here. */}
<motion.span
animate={reduced ? { opacity: 1 } : { opacity: BLINK.opacity }}
className="z-20 h-[18px] w-px bg-gray-1000"
transition={reduced ? { duration: 0 } : BLINK.transition}
/>
</div>
)}
{/* The ring, and not a darker border, marks the active box. A border that
changes colour makes all six boxes flicker as the caret moves; an
inset overlay leaves them alone. It stays on the last box once the
code is complete, which is where the caret would be. */}
{isActive && (
<div className="absolute inset-0 z-10 rounded-[inherit] ring-2 ring-gray-700 contrast-more:ring-gray-900" />
)}
</div>
)
}
export function InputOtp({
length = 6,
onComplete,
}: {
length?: number
onComplete?: (value: string) => void
}) {
const [value, setValue] = useState("")
const reduced = useReducedMotion() ?? false
const half = Math.ceil(length / 2)
return (
<OTPInput
aria-label="Verification code"
// `autocomplete="one-time-code"` is what makes iOS and macOS offer the
// code from Messages above the keyboard. `inputMode` summons the numeric
// pad without the letters a text keyboard would show.
autoComplete="one-time-code"
containerClassName="flex items-center gap-2 has-disabled:opacity-50"
inputMode="numeric"
maxLength={length}
onChange={setValue}
onComplete={onComplete}
pattern="^\d+$"
render={({ slots }) => (
<>
<div className="flex items-center gap-1">
{slots.slice(0, half).map((slot, i) => (
<Slot key={i} {...slot} reduced={reduced} />
))}
</div>
{/* Two groups rather than six boxes in a row: the eye reads a code in
halves, and the pill gives it somewhere to rest. 8px wide and 2px
tall, the same colour as the borders it sits between. */}
<div className="h-0.5 w-2 rounded-full bg-gray-700 contrast-more:bg-gray-900" />
<div className="flex items-center gap-1">
{slots.slice(half).map((slot, i) => (
<Slot key={i + half} {...slot} reduced={reduced} />
))}
</div>
</>
)}
value={value}
/>
)
}