FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Input OTP

Input OTP

pnpm dlx shadcn@latest add …/r/input-otp.json

An accessible React one-time code input for Next.js and Tailwind: boxes that move on as you type and take a pasted code, built on Radix.

Verification code

Type or paste the 6 digits. 123456 works; anything else fails.

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function VerificationCode() {
  return (
    <InputOTP aria-label="Verification code" name="code">
      <InputOTPGroup>
        <InputOTPSlot index={0} />
        <InputOTPSlot index={1} />
        <InputOTPSlot index={2} />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot index={3} />
        <InputOTPSlot index={4} />
        <InputOTPSlot index={5} />
      </InputOTPGroup>
    </InputOTP>
  )
}
code.tsx
"use client";

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { duration, ease, swap } from "@/lib/motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useState } from "react";

type Size = "sm" | "md" | "lg";

/** How long the pretend server takes to check a code, in ms */
const CHECK = 600;
const RIGHT = "123456";

/*
 * A six-digit code in two groups of three, with the size in the top-right corner. Once all six digits are
 * in, the code is checked for a moment, then the boxes run their success wave (123456) or the field shakes
 * its head (anything else), so the whole range shows here. The message under it swaps through the blur in
 * a polite live region; change a digit and it waits again. An empty row at the bottom, as tall as the
 * setting's row, keeps the code centred.
 */
export default function InputOTPCode() {
  const id = useId();
  const reduce = useReducedMotion();
  const [size, setSize] = useState<Size>("md");
  const [code, setCode] = useState("");
  const [result, setResult] = useState<InputOTPStatus | "checking" | null>(null);

  useEffect(() => {
    if (code.length < RIGHT.length) {
      setResult(null);
      return;
    }
    setResult("checking");
    const timer = setTimeout(() => setResult(code === RIGHT ? "success" : "error"), CHECK);
    return () => clearTimeout(timer);
  }, [code]);

  const message =
    result === "checking" ? (
      <span className="flex items-center gap-2 text-muted-foreground">
        <Spinner size="sm" aria-hidden />
        Checking the code…
      </span>
    ) : result === "success" ? (
      <span className="flex items-center gap-2 text-foreground">
        <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-(--size-icon-sm)">
          <motion.path
            d="M5 12.5l4.5 4.5L19 7.5"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={{ pathLength: reduce ? 1 : 0 }}
            animate={{ pathLength: 1 }}
            transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }}
          />
        </svg>
        Code verified
      </span>
    ) : result === "error" ? (
      <span className="text-destructive">That code isn’t right. Try {RIGHT}.</span>
    ) : (
      <span className="text-muted-foreground">
        Type or paste the 6 digits. {RIGHT} works; anything else fails.
      </span>
    );

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
          Size
        </label>
        <Select value={size} onValueChange={(value) => setSize(value as Size)}>
          <SelectTrigger id={`${id}-size`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="sm">Small</SelectItem>
            <SelectItem value="md">Medium</SelectItem>
            <SelectItem value="lg">Large</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-col items-center justify-center gap-3">
        <span id={`${id}-label`} className="text-sm font-medium">
          Verification code
        </span>
        <InputOTP
          size={size}
          value={code}
          onValueChange={setCode}
          status={result === "success" || result === "error" ? result : undefined}
          aria-labelledby={`${id}-label`}
          aria-describedby={`${id}-message`}
        >
          <InputOTPGroup>
            <InputOTPSlot index={0} />
            <InputOTPSlot index={1} />
            <InputOTPSlot index={2} />
          </InputOTPGroup>
          <InputOTPSeparator />
          <InputOTPGroup>
            <InputOTPSlot index={3} />
            <InputOTPSlot index={4} />
            <InputOTPSlot index={5} />
          </InputOTPGroup>
        </InputOTP>
        <div id={`${id}-message`} aria-live="polite" className="grid text-sm">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.p
              key={result ?? "waiting"}
              initial={reduce ? { opacity: 0 } : swap.initial}
              animate={reduce ? { opacity: 1 } : swap.animate}
              exit={reduce ? { opacity: 0 } : swap.exit}
              transition={reduce ? { duration: 0 } : { duration: duration.base, ease: ease.out }}
              className="col-start-1 row-start-1 justify-self-center"
            >
              {message}
            </motion.p>
          </AnimatePresence>
        </div>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/input-otp.tsx
    "use client";
    
    import { duration, ease, spring, swap } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { MinusIcon } from "lucide-react";
    import { AnimatePresence, animate, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { unstable_OneTimePasswordField as OneTimePasswordField } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      useContext,
      useEffect,
      useId,
      useLayoutEffect,
      useRef,
      useState,
    } from "react";
    
    /*
     * Input OTP: one-time code boxes on Radix One-Time Password Field (owner, 2026-10-16). Radix types each
     * character into its own input and moves on, goes back with Backspace, spreads a pasted or autofilled code
     * across the boxes, and submits the code through a hidden input.
     * - Motion (.agents/rules/motion.md):
     *   - One focus box per field glides to the next box as you type (layoutId on spring.snappy, scoped to the
     *     field with LayoutGroup id={useId()}), so the eye follows where the next character goes.
     *   - A typed character rises 6px into its box through the blur (the library's `swap`). Typed over, the old
     *     one lifts away as the new rises; deleted, it sinks back the way it came.
     *   - A pasted code comes in left to right, staggered by 0.05s like items in a list.
     *   - The empty focused box shows a soft caret that breathes rather than blinks. The native caret and text
     *     are transparent; what you see is drawn over the input.
     *   - status="error": the field shakes its head, a quick side-to-side that dies away, while every border
     *     and the focus box turn destructive (owner, 2026-10-21). It shakes each time the status becomes error.
     *   - status="success": a wave runs left to right, each box swelling 6% and settling again (duration.slow,
     *     ease.out), 0.05s apart, as its border turns solid. Neutral, like Loading Button's success.
     * - Nothing animates on first paint. With reduced motion the characters only fade, the box jumps, the
     *   caret stays still, and success and error only change colour.
     * - Boxes match Input: control heights, Input's border and focus ring, a destructive border when invalid.
     */
    
    type Size = "sm" | "md" | "lg";
    export type InputOTPStatus = "success" | "error";
    
    /** Items in a list come in 0.05s apart (motion.md); a pasted code's characters do the same */
    const STAGGER = 0.05;
    /** The head shake, in px: out to one side and back, smaller each time */
    const SHAKE = [0, -8, 7, -5, 3, -1, 0];
    /** How much a box swells as the success wave passes */
    const SWELL = 1.06;
    /** Keys that move between boxes, which bring the focus box back on a full code */
    const MOVES = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]);
    
    type FieldContextValue = {
      value: string;
      /** The box that has focus, which the focus box sits on */
      active: number | null;
      setActive: (index: number | null) => void;
      size: Size;
      password: boolean;
      invalid: boolean;
      success: boolean;
      disabled: boolean;
      reduce: boolean;
      /** Several characters arrived at once (a paste or autofill): where they start, so they come in in turn */
      batchFrom: number | null;
      /** Every box is filled and you haven't moved since: the focus box fades away */
      focusHidden: boolean;
      /** Brings the focus box back */
      reveal: () => void;
    };
    
    const FieldContext = createContext<FieldContextValue | null>(null);
    
    const slotSizes = {
      sm: "size-(--size-control-sm) rounded-md text-sm",
      md: "size-(--size-control-md) rounded-lg text-base",
      lg: "size-(--size-control-lg) rounded-lg text-base",
    } as const;
    const indicatorRadius = { sm: "rounded-md", md: "rounded-lg", lg: "rounded-lg" } as const;
    const caretHeights = { sm: "h-3.5", md: "h-4", lg: "h-5" } as const;
    const dotSizes = { sm: "size-1.5", md: "size-2", lg: "size-2" } as const;
    
    export type InputOTPProps = Omit<ComponentProps<typeof OneTimePasswordField.Root>, "size"> & {
      /** The boxes' size, matching Input's heights */
      size?: Size;
      /**
       * The result of checking the code: success runs a wave along the boxes, error shakes the field and marks
       * it invalid. Leave it unset while the code is being typed or checked.
       */
      status?: InputOTPStatus;
    };
    
    /**
     * A one-time code field: put InputOTPSlot boxes (in InputOTPGroups, with an InputOTPSeparator between them)
     * inside. The number of slots is the code's length. Give it a name with aria-label or aria-labelledby, and
     * set aria-invalid when the code is wrong.
     */
    function InputOTP({
      className,
      children,
      size = "md",
      value,
      defaultValue,
      onValueChange,
      type,
      disabled = false,
      status,
      "aria-invalid": ariaInvalid,
      ref,
      onKeyDown,
      onPointerDown,
      ...props
    }: InputOTPProps) {
      // Mirror the value, controlled or not, so each box can draw its own character
      const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
      const current = value ?? uncontrolled;
      const [active, setActive] = useState<number | null>(null);
      const reduce = useReducedMotion() ?? false;
      const groupId = useId();
      const invalid = ariaInvalid === true || ariaInvalid === "true" || status === "error";
      const root = useRef<HTMLDivElement | null>(null);
    
      // The head shake, each time the status becomes error (never on first paint, nor with reduced motion)
      const lastStatus = useRef(status);
      useEffect(() => {
        const before = lastStatus.current;
        lastStatus.current = status;
        const node = root.current;
        if (status !== "error" || before === "error" || reduce || !node) return;
        const shake = animate(node, { x: SHAKE }, { duration: duration.slower, ease: ease.out });
        shake.then(() => node.style.removeProperty("transform"));
        return () => {
          shake.stop();
          node.style.removeProperty("transform");
        };
      }, [status, reduce]);
    
      // Once every box is filled the focus box fades away: the code is done, and the boxes read as a whole. It
      // comes back the moment you move (arrow keys, Home, End, a click), arrive from outside, or change a digit,
      // so the keyboard is never left without a visible focus.
      const [slots, setSlots] = useState(0);
      const [revealed, setRevealed] = useState(false);
      useLayoutEffect(() => {
        const count = root.current?.querySelectorAll("[data-slot=input-otp-slot]").length ?? 0;
        if (count !== slots) setSlots(count);
      });
      const focusHidden = slots > 0 && current.length >= slots && !revealed;
    
      // Which characters just changed. More than one at once is a paste or autofill, so they come in in turn.
      const [seen, setSeen] = useState<{ value: string; batchFrom: number | null }>({
        value: current,
        batchFrom: null,
      });
      let batchFrom = seen.batchFrom;
      if (seen.value !== current) {
        const changed: number[] = [];
        for (let index = 0; index < current.length; index++) {
          if (current[index] !== seen.value[index]) changed.push(index);
        }
        batchFrom = changed.length > 1 ? (changed[0] ?? null) : null;
        setSeen({ value: current, batchFrom });
        setRevealed(false);
      }
    
      return (
        <FieldContext.Provider
          value={{
            value: current,
            active,
            setActive,
            size,
            password: type === "password",
            invalid,
            success: status === "success",
            disabled,
            reduce,
            batchFrom,
            focusHidden,
            reveal: () => setRevealed(true),
          }}
        >
          <OneTimePasswordField.Root
            ref={(node) => {
              root.current = node;
              if (typeof ref === "function") ref(node);
              else if (ref) ref.current = node;
            }}
            data-slot="input-otp"
            data-status={status}
            value={current}
            onValueChange={(next) => {
              if (value === undefined) setUncontrolled(next);
              onValueChange?.(next);
            }}
            type={type}
            disabled={disabled}
            onKeyDown={(event) => {
              if (MOVES.has(event.key)) setRevealed(true);
              onKeyDown?.(event);
            }}
            onPointerDown={(event) => {
              setRevealed(true);
              onPointerDown?.(event);
            }}
            className={cn("flex items-center gap-2", className)}
            {...props}
          >
            <LayoutGroup id={groupId}>{children}</LayoutGroup>
            <OneTimePasswordField.HiddenInput />
          </OneTimePasswordField.Root>
        </FieldContext.Provider>
      );
    }
    
    /** Boxes that sit together, such as the first three digits of a six-digit code */
    function InputOTPGroup({ className, ...props }: ComponentProps<"div">) {
      return <div data-slot="input-otp-group" className={cn("flex items-center gap-2", className)} {...props} />;
    }
    
    export type InputOTPSlotProps = ComponentProps<typeof OneTimePasswordField.Input> & {
      /** The box's place in the code, from 0 */
      index: number;
    };
    
    /**
     * One box. `className` styles the box; the other props go to its input. Radix names each input
     * "Character 1 of 6" and so on; pass aria-label to change it.
     */
    function InputOTPSlot({ index, className, onFocus, onBlur, ...props }: InputOTPSlotProps) {
      const field = useContext(FieldContext);
      const size = field?.size ?? "md";
      const reduce = field?.reduce ?? false;
      const char = field?.value[index] ?? "";
      const active = field?.active === index;
      const delay =
        field?.batchFrom != null && index >= field.batchFrom ? (index - field.batchFrom) * STAGGER : 0;
    
      return (
        <motion.div
          data-slot="input-otp-slot"
          data-active={active || undefined}
          data-filled={char ? "" : undefined}
          data-invalid={field?.invalid || undefined}
          data-success={field?.success || undefined}
          data-disabled={field?.disabled || undefined}
          // The success wave: each box swells and settles in turn. A named state, so it plays once per success.
          initial={false}
          animate={field?.success && !reduce ? "success" : "rest"}
          variants={{
            rest: { scale: 1 },
            success: {
              scale: [1, SWELL, 1],
              transition: { duration: duration.slow, ease: ease.out, delay: index * STAGGER },
            },
          }}
          className={cn(
            "relative grid shrink-0 place-items-center border border-input bg-transparent font-medium text-foreground tabular-nums shadow-xs",
            "transition-colors duration-(--duration-base) ease-out data-invalid:border-destructive data-success:border-primary",
            "data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)",
            slotSizes[size],
            className,
          )}
        >
          {/* The real input fills the box. Its text, caret and selection are transparent: the character, the caret
              and the focus box are drawn over it, so they can move */}
          <OneTimePasswordField.Input
            index={index}
            data-slot="input-otp-input"
            aria-invalid={field?.invalid || undefined}
            onFocus={(event) => {
              field?.setActive(index);
              // Arriving from outside the field (Tab), the focus box shows even on a full code
              const from = event.relatedTarget;
              const root = event.currentTarget.closest("[data-slot=input-otp]");
              if (!(from instanceof Node && root?.contains(from))) field?.reveal();
              onFocus?.(event);
            }}
            onBlur={(event) => {
              // Moving to another box keeps the focus box (it glides there); leaving the field clears it
              const next = event.relatedTarget;
              const root = event.currentTarget.closest("[data-slot=input-otp]");
              if (!(next instanceof Node && root?.contains(next))) field?.setActive(null);
              onBlur?.(event);
            }}
            className="absolute inset-0 size-full cursor-text bg-transparent text-center text-transparent caret-transparent outline-hidden selection:bg-transparent placeholder:text-muted-foreground disabled:cursor-not-allowed"
            {...props}
          />
    
          {/* Deleted, the character sinks back the way it came; typed over, it lifts away as the new one rises */}
          <AnimatePresence initial={false} custom={char ? "up" : "down"}>
            {char ? (
              <motion.span
                key={char}
                aria-hidden
                data-slot="input-otp-char"
                className="pointer-events-none col-start-1 row-start-1"
                initial="hidden"
                animate="shown"
                exit="gone"
                variants={{
                  hidden: reduce ? { opacity: 0 } : swap.initial,
                  shown: {
                    ...(reduce ? { opacity: 1 } : swap.animate),
                    transition: reduce
                      ? { duration: duration.fast, delay }
                      : { duration: duration.base, ease: ease.out, delay, y: { ...spring.snappy, delay } },
                  },
                  gone: (leave: "up" | "down") => ({
                    ...(reduce ? { opacity: 0 } : leave === "down" ? swap.initial : swap.exit),
                    transition: reduce
                      ? { duration: duration.fast }
                      : { duration: duration.base, ease: ease.out, y: spring.snappy },
                  }),
                }}
              >
                {field?.password ? (
                  <span className={cn("block rounded-full bg-current", dotSizes[size])} />
                ) : (
                  char
                )}
              </motion.span>
            ) : null}
          </AnimatePresence>
    
          {active && !char ? (
            <motion.span
              aria-hidden
              data-slot="input-otp-caret"
              className={cn(
                "pointer-events-none col-start-1 row-start-1 w-px rounded-full bg-foreground",
                caretHeights[size],
              )}
              // A soft caret: it breathes once a second instead of blinking hard. Still with reduced motion.
              animate={reduce ? { opacity: 1 } : { opacity: [1, 0, 1] }}
              transition={
                reduce
                  ? { duration: 0 }
                  : { duration: duration.slower * 2, ease: ease.inOut, repeat: Number.POSITIVE_INFINITY }
              }
            />
          ) : null}
    
          {active ? (
            // One focus box per field: Input's focus border and ring, gliding from box to box
            <motion.span
              layoutId="input-otp-indicator"
              aria-hidden
              data-slot="input-otp-indicator"
              data-hidden={field?.focusHidden || undefined}
              className={cn(
                "pointer-events-none absolute -inset-px border border-ring outline-(length:--focus-ring-width) outline-offset-(--focus-ring-offset) outline-ring",
                field?.invalid && "border-destructive outline-destructive",
                indicatorRadius[size],
              )}
              // A ring, so it only fades as the code fills and comes back when you move
              initial={false}
              animate={{ opacity: field?.focusHidden ? 0 : 1 }}
              transition={
                reduce
                  ? { duration: 0 }
                  : { ...spring.snappy, opacity: { duration: duration.base, ease: ease.out } }
              }
            />
          ) : null}
        </motion.div>
      );
    }
    
    /** A dash between groups of boxes. Pass children to draw something else. */
    function InputOTPSeparator({ className, children, ...props }: ComponentProps<"div">) {
      return (
        // biome-ignore lint/a11y/useSemanticElements: it holds an icon, which an hr can't; shadcn's has the same role
        <div
          data-slot="input-otp-separator"
          role="separator"
          className={cn("flex items-center text-muted-foreground", className)}
          {...props}
        >
          {children ?? <MinusIcon aria-hidden className="size-(--size-icon-md)" />}
        </div>
      );
    }
    
    export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
  5. 5

    Update the import paths to match your project.

Examples

PIN

type="password" draws a dot for each digit. Turn autocomplete off for a PIN.

PIN
pin.tsx
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { useId } from "react";

/** A four-digit PIN: type="password" draws a dot for each digit, and autocomplete is off */
export default function InputOTPPin() {
  const id = useId();
  return (
    <div className="grid justify-items-center gap-3">
      <span id={id} className="text-sm font-medium">
        PIN
      </span>
      <InputOTP type="password" autoComplete="off" aria-labelledby={id}>
        <InputOTPGroup>
          <InputOTPSlot index={0} />
          <InputOTPSlot index={1} />
          <InputOTPSlot index={2} />
          <InputOTPSlot index={3} />
        </InputOTPGroup>
      </InputOTP>
    </div>
  );
}

API reference

InputOTP

Also accepts every Radix One-Time Password Field prop. The number of InputOTPSlots sets the code's length (shadcn's maxLength isn't needed), and the change callback is onValueChange.

PropTypeDefaultDescription
value?string–The code, when you control it.
defaultValue?string–The code at first.
onValueChange?(value: string) => void–Called with the whole code after every change.
size?"sm" | "md" | "lg""md"32, 36 or 40px boxes, matching Input's heights.
validationType?"numeric" | "alpha" | "alphanumeric" | "none""numeric"Which characters a box accepts; others are ignored. Use sanitizeValue with none for your own rule.
type?"text" | "password""text"password hides each character behind a dot.
autoSubmit?booleanfalseSubmits the form once every box is filled. onAutoSubmit is called first.
autoComplete?"one-time-code" | "off""one-time-code"Lets the phone or a password manager fill in a code it received.
name?string–Submits the code with a form, through a hidden input.
status?"success" | "error"–The result of checking the code. success runs a wave along the boxes and makes their borders solid; error shakes the field and marks it invalid. Leave it unset while the code is typed or checked.
aria-invalid?boolean–Marks the code as wrong without the shake: the boxes and focus box turn destructive.
disabled?booleanfalsePrevents typing.
placeholder?string–One character per box, shown while the code is empty.

InputOTPGroup

Boxes that sit together, such as the first three digits. A div.

PropTypeDefaultDescription

InputOTPSlot

One box. className styles the box; other props go to its input, which Radix names “Character 1 of 6”.

PropTypeDefaultDescription
indexnumber–The box's place in the code, from 0.

InputOTPSeparator

A dash between groups (role separator). Pass children to draw something else.

PropTypeDefaultDescription

Related components

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.

  • 3
    Number Input

    An accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.

  • Button

    An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.