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}
    />
  )
}