Aura Design System

Input OTP

A one-time code built from SegmentedInput.

Preview

Loading...
import { useState } from "react";
import { InputOTP } from "@/components/ui/InputOTP";

export function InputOTPDemo() {
  const [value, setValue] = useState("");

  return (
    <div className="grid gap-1">
      <InputOTP
        value={value}
        onValueChange={setValue}
        separatorAfter={3}
        name="code"
      />
      <p className="text-sm text-gray-11">Code: {value || "empty"}</p>
    </div>
  );
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/input-otp

Manual

Install the following dependencies:

pnpm install @radix-ui/react-icons

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the segmented input component into your components/ui/SegmentedInput.tsx file.

"use client";

/**
 * @description A group of connected input fields that appear as a single segmented visual unit.
 */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
  Direction as DirectionPrimitive,
  Slot as SlotPrimitive,
} from "radix-ui";

import { cn } from "@/utils/class-names";
import { Input } from "@/components/ui/Input";

const ROOT_NAME = "SegmentedInput";
const ITEM_NAME = "SegmentedInputItem";

type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
type Size = "default" | "sm" | "lg";
type Position = "isolated" | "first" | "middle" | "last";

interface SegmentedInputContextValue {
  dir?: Direction;
  orientation?: Orientation;
  size?: Size;
  disabled?: boolean;
  invalid?: boolean;
  required?: boolean;
}

const SegmentedInputContext =
  React.createContext<SegmentedInputContextValue | null>(null);

function useSegmentedInputContext(consumerName: string) {
  const context = React.useContext(SegmentedInputContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}

interface SegmentedInputProps extends React.ComponentProps<"div"> {
  dir?: Direction;
  orientation?: Orientation;
  size?: Size;
  asChild?: boolean;
  disabled?: boolean;
  invalid?: boolean;
  required?: boolean;
}

function SegmentedInput(props: SegmentedInputProps) {
  const {
    size = "default",
    dir: dirProp,
    orientation = "horizontal",
    children,
    className,
    asChild,
    disabled,
    invalid,
    required,
    ...rootProps
  } = props;

  const dir = DirectionPrimitive.useDirection(dirProp);

  const contextValue = React.useMemo<SegmentedInputContextValue>(
    () => ({
      dir,
      orientation,
      size,
      disabled,
      invalid,
      required,
    }),
    [dir, orientation, size, disabled, invalid, required],
  );

  const childrenArray = React.Children.toArray(children);
  const childrenCount = childrenArray.length;

  const segmentedInputItems = React.Children.map(children, (child, index) => {
    if (React.isValidElement<SegmentedInputItemProps>(child)) {
      if (!child.props.position) {
        let position: Position;

        if (childrenCount === 1) {
          position = "isolated";
        } else if (index === 0) {
          position = "first";
        } else if (index === childrenCount - 1) {
          position = "last";
        } else {
          position = "middle";
        }

        return React.cloneElement(child, { position });
      }
    }
    return child;
  });

  const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";

  return (
    <SegmentedInputContext.Provider value={contextValue}>
      <RootPrimitive
        role="group"
        aria-orientation={orientation}
        data-slot="segmented-input"
        data-orientation={orientation}
        data-disabled={disabled ? "" : undefined}
        data-invalid={invalid ? "" : undefined}
        data-required={required ? "" : undefined}
        dir={dir}
        {...rootProps}
        className={cn(
          "flex",
          orientation === "horizontal" ? "flex-row" : "flex-col",
          className,
        )}
      >
        {segmentedInputItems}
      </RootPrimitive>
    </SegmentedInputContext.Provider>
  );
}

const segmentedInputItemVariants = cva(
  [
    "relative min-w-0 flex-1 rounded-md border border-gray-7 bg-gray-1 text-gray-12 transition-colors outline-none",
    "selection:bg-accent-4 selection:text-gray-12 placeholder:text-gray-11",
    "focus-visible:z-10 focus-visible:border-gray-8 focus-visible:ring-2 focus-visible:ring-gray-8",
    "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
    "aria-invalid:border-danger aria-invalid:ring-2 aria-invalid:ring-danger/20",
  ].join(" "),
  {
    variants: {
      position: {
        isolated: "",
        first: "rounded-e-none",
        middle: "-ms-px rounded-none border-l-0",
        last: "-ms-px rounded-s-none border-l-0",
      },
      orientation: {
        horizontal: "",
        vertical: "",
      },
      size: {
        // Aura --spacing 13px: avoid text-xs/text-sm on editable inputs (iOS zoom)
        sm: "h-2.5 px-1",
        default: "h-3 px-1",
        lg: "h-4 px-1.5",
      },
    },
    compoundVariants: [
      {
        position: "first",
        orientation: "vertical",
        class: "ms-0 rounded-e-md rounded-b-none border-l",
      },
      {
        position: "middle",
        orientation: "vertical",
        class: "ms-0 -mt-px rounded-none border-t-0 border-l",
      },
      {
        position: "last",
        orientation: "vertical",
        class: "ms-0 -mt-px rounded-s-md rounded-t-none border-t-0 border-l",
      },
    ],
    defaultVariants: {
      position: "isolated",
      orientation: "horizontal",
      size: "default",
    },
  },
);

interface SegmentedInputItemProps
  extends
    React.ComponentProps<"input">,
    Omit<VariantProps<typeof segmentedInputItemVariants>, "size"> {
  asChild?: boolean;
}

function SegmentedInputItem(props: SegmentedInputItemProps) {
  const { asChild, className, position, disabled, required, ...inputProps } =
    props;
  const context = useSegmentedInputContext(ITEM_NAME);

  const isDisabled = disabled ?? context.disabled;
  const isRequired = required ?? context.required;

  const ItemPrimitive = asChild ? SlotPrimitive.Slot : Input;

  return (
    <ItemPrimitive
      aria-invalid={context.invalid}
      aria-required={isRequired}
      data-disabled={isDisabled ? "" : undefined}
      data-invalid={context.invalid ? "" : undefined}
      data-orientation={context.orientation}
      data-position={position}
      data-required={isRequired ? "" : undefined}
      data-slot="segmented-input-item"
      disabled={isDisabled}
      required={isRequired}
      {...inputProps}
      className={cn(
        segmentedInputItemVariants({
          position,
          orientation: context.orientation,
          size: context.size,
          className,
        }),
      )}
    />
  );
}

export {
  SegmentedInput,
  SegmentedInputItem,
  SegmentedInput as Root,
  SegmentedInputItem as Item,
  segmentedInputItemVariants,
  type SegmentedInputProps,
  type SegmentedInputItemProps,
};

Copy and paste the InputOTP component into your components/ui/InputOTP.tsx file.

"use client";
/**
 * @description One-time code entry built from SegmentedInput.
 */
import * as React from "react";
import { MinusIcon } from "@radix-ui/react-icons";

import {
  SegmentedInput,
  SegmentedInputItem,
} from "@/components/ui/SegmentedInput";
import { cn } from "@/utils/class-names";

function InputOTP({
  length = 6,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  disabled,
  invalid,
  id,
  name,
  separatorAfter,
  className,
}: {
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  disabled?: boolean;
  invalid?: boolean;
  id?: string;
  name?: string;
  separatorAfter?: number;
  className?: string;
}) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = (valueProp ?? uncontrolled).slice(0, length);
  const refs = React.useRef<Array<HTMLInputElement | null>>([]);

  function commit(next: string) {
    const clipped = next.replace(/\D/g, "").slice(0, length);
    if (valueProp === undefined) setUncontrolled(clipped);
    onValueChange?.(clipped);
  }

  function focusAt(index: number) {
    refs.current[Math.max(0, Math.min(index, length - 1))]?.focus();
  }

  const indexes = Array.from({ length }, (_, index) => index);
  const groups =
    separatorAfter && separatorAfter > 0 && separatorAfter < length
      ? [indexes.slice(0, separatorAfter), indexes.slice(separatorAfter)]
      : [indexes];

  return (
    <div className={cn("flex items-center gap-1", className)}>
      {groups.map((group, groupIndex) => (
        <React.Fragment key={group[0]}>
          {groupIndex > 0 ? <MinusIcon className="icon" aria-hidden /> : null}
          <SegmentedInput
            disabled={disabled}
            invalid={invalid}
            aria-label="One-time code"
          >
            {group.map((index) => (
              <SegmentedInputItem
                key={index}
                id={index === 0 ? id : undefined}
                ref={(node) => {
                  refs.current[index] = node;
                }}
                value={value[index] ?? ""}
                inputMode="numeric"
                autoComplete={index === 0 ? "one-time-code" : "off"}
                autoCapitalize="off"
                spellCheck={false}
                aria-label={`Digit ${index + 1} of ${length}`}
                maxLength={length}
                className="w-3 flex-none text-center"
                onChange={(event) => {
                  const raw = event.target.value.replace(/\D/g, "");
                  if (!raw) {
                    commit(value.slice(0, index) + value.slice(index + 1));
                    return;
                  }
                  const next = (value.slice(0, index) + raw).slice(0, length);
                  commit(next);
                  focusAt(index + raw.length);
                }}
                onKeyDown={(event) => {
                  if (event.key === "Backspace" && !value[index] && index > 0) {
                    commit(value.slice(0, index - 1) + value.slice(index));
                    focusAt(index - 1);
                  }
                  if (event.key === "ArrowLeft") focusAt(index - 1);
                  if (event.key === "ArrowRight") focusAt(index + 1);
                }}
                onPaste={(event) => {
                  event.preventDefault();
                  const text = event.clipboardData
                    .getData("text")
                    .replace(/\D/g, "");
                  const next = (value.slice(0, index) + text).slice(0, length);
                  commit(next);
                  focusAt(index + text.length);
                }}
              />
            ))}
          </SegmentedInput>
        </React.Fragment>
      ))}
      {name ? <input type="hidden" name={name} value={value} /> : null}
    </div>
  );
}

export { InputOTP };