Skip to main content
Nexus Design System
GitHub

InputOTP

InputOTP collects a one-time code, with one slot per character. Use it for verification and sign-in codes. For other short text, use Input.

'use client';

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from '@/components/nxds/components/input-otp/input-otp';

function SixDigitSlots() {
  return (
    <InputOTPGroup>
      {Array.from({ length: 6 }, (_, i) => (
        <InputOTPSlot key={i} index={i} />
      ))}
    </InputOTPGroup>
  );
}

export default function InputOtpDefault() {
  return (
    <InputOTP maxLength={6} aria-label="One-time password">
      <SixDigitSlots />
    </InputOTP>
  );
}

Installation

npx nxds-cli@latest add input-otp

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from '@/components/nxds/components/input-otp/input-otp';

Composition

InputOTP
├── InputOTPGroup
│   └── InputOTPSlot
└── InputOTPSeparator

Examples

With separator

Split a six-digit code into two groups of three with a separator.

'use client';

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from '@/components/nxds/components/input-otp/input-otp';

export default function InputOtpWithSeparator() {
  return (
    <InputOTP maxLength={6} aria-label="One-time password">
      <InputOTPGroup>
        <InputOTPSlot index={0} />
        <InputOTPSlot index={1} />
        <InputOTPSlot index={2} />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot index={3} />
        <InputOTPSlot index={4} />
        <InputOTPSlot index={5} />
      </InputOTPGroup>
    </InputOTP>
  );
}

Disabled

Dim every slot and block input while the field is disabled.

'use client';

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from '@/components/nxds/components/input-otp/input-otp';

function SixDigitSlots() {
  return (
    <InputOTPGroup>
      {Array.from({ length: 6 }, (_, i) => (
        <InputOTPSlot key={i} index={i} />
      ))}
    </InputOTPGroup>
  );
}

export default function InputOtpDisabled() {
  return (
    <InputOTP maxLength={6} aria-label="One-time password" disabled>
      <SixDigitSlots />
    </InputOTP>
  );
}

Props

InputOTP

InputOTP props
PropTypeDefaultDescription
childrenReactNodeNo default documented

InputOTPGroup, InputOTPSlot and InputOTPSeparator elements.

InputOTPGroup

InputOTPGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

InputOTPSeparator

InputOTPSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

InputOTPSlot

InputOTPSlot props
PropTypeDefaultDescription
indexRequirednumberNo default documented

Position of the slot in the code, starting at 0.

InputOTP collects a one-time code, with one slot per character. Use it for verification and sign-in codes. For other short text, use Input.

<script setup lang="ts">
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/input-otp';
</script>

<template>
  <InputOTP aria-label="One-time password">
    <InputOTPGroup>
      <InputOTPSlot :index="0" />
      <InputOTPSlot :index="1" />
      <InputOTPSlot :index="2" />
      <InputOTPSlot :index="3" />
      <InputOTPSlot :index="4" />
      <InputOTPSlot :index="5" />
    </InputOTPGroup>
  </InputOTP>
</template>

Installation

  1. Install the following dependencies:

    npm install @phosphor-icons/vue@^2.2.1 @vueuse/core@^14.1.0 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/input-otp/InputOTP.vue
    <script setup lang="ts">
    import { reactiveOmit, useVModel } from '@vueuse/core';
    import { PinInputRoot, type PinInputRootProps, useForwardProps } from 'reka-ui';
    import { computed, type HTMLAttributes, provide, useAttrs } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { InputOTPKey } from '.';
    
    export interface InputOTPProps extends Omit<
      PinInputRootProps,
      'modelValue' | 'defaultValue' | 'otp'
    > {
      modelValue?: string;
      defaultValue?: string;
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = defineProps<InputOTPProps>();
    const emits = defineEmits<{
      'update:modelValue': [value: string];
      complete: [value: string];
    }>();
    
    // Reka holds one character per slot; the code is one string, as in React.
    const model = useVModel(props, 'modelValue', emits, {
      passive: true,
      defaultValue: props.defaultValue ?? '',
    });
    const characters = computed(() => [...(model.value ?? '')]);
    
    // The root is a role-less <div>, so each slot's <input> takes the naming and
    // invalid ARIA instead.
    const attrs = useAttrs();
    const slotAttrs = computed(() => ({
      'aria-label': attrs['aria-label'],
      'aria-labelledby': attrs['aria-labelledby'],
      'aria-invalid': attrs['aria-invalid'],
    }));
    provide(InputOTPKey, slotAttrs);
    
    function rootAttrs() {
      return Object.fromEntries(
        Object.entries(attrs).filter(([name]) => !(name in slotAttrs.value))
      );
    }
    
    const delegated = reactiveOmit(props, 'class', 'modelValue', 'defaultValue');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <PinInputRoot
        v-bind="{ ...forwarded, ...rootAttrs() }"
        :model-value="characters"
        otp
        data-slot="input-otp"
        :class="
          cn('nx:group/input-otp nx:flex nx:items-center nx:gap-2', props.class)
        "
        @update:model-value="model = $event.join('')"
        @complete="emits('complete', $event.join(''))"
      >
        <slot />
      </PinInputRoot>
    </template>
    
    components/input-otp/InputOTPGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface InputOTPGroupProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<InputOTPGroupProps>();
    </script>
    
    <template>
      <div
        data-slot="input-otp-group"
        :class="cn('nx:isolate nx:flex nx:items-center', props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/input-otp/InputOTPSeparator.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { IconCircleFilled } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    export interface InputOTPSeparatorProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<InputOTPSeparatorProps>();
    </script>
    
    <template>
      <div
        data-slot="input-otp-separator"
        aria-hidden="true"
        :class="cn('nx:flex nx:items-center', props.class)"
      >
        <IconCircleFilled class="nx:size-1 nx:text-muted-foreground" />
      </div>
    </template>
    
    components/input-otp/InputOTPSlot.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      PinInputInput,
      type PinInputInputProps,
      useForwardProps,
    } from 'reka-ui';
    import { type HTMLAttributes, inject, ref } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { InputOTPKey } from '.';
    
    export interface InputOTPSlotProps extends Omit<PinInputInputProps, 'index'> {
      /**
       * Position of the slot in the code, starting at 0.
       */
      index: number;
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<InputOTPSlotProps>();
    
    const otpAttrs = inject(InputOTPKey)!;
    const active = ref(false);
    
    function nameAttrs() {
      const { 'aria-label': label, ...shared } = otpAttrs.value;
      if (typeof label !== 'string') return shared;
      return { ...shared, 'aria-label': `${label}, character ${props.index + 1}` };
    }
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <PinInputInput
        v-bind="{ ...forwarded, ...nameAttrs() }"
        data-slot="input-otp-slot"
        :data-active="active"
        :class="
          cn(
            'nx:relative nx:flex nx:box-border nx:size-10 nx:items-center nx:justify-center',
            'nx:border-default nx:border-border-default nx:not-first:-ml-(--nx-borderwidth-default)',
            'nx:group-has-[:disabled]/input-otp:border-border-disabled nx:group-has-[:disabled]/input-otp:bg-disabled nx:group-has-[:disabled]/input-otp:text-disabled-foreground',
            'nx:bg-container nx:text-foreground nx:typography-body-small nx:transition-field nx:duration-fast',
            'nx:first:rounded-l-md nx:last:rounded-r-md',
            'nx:data-[active=true]:z-1 nx:data-[active=true]:border-focus-default nx:data-[active=true]:outline-default nx:data-[active=true]:outline-focus-default',
            'nx:m-0 nx:p-0 nx:text-center nx:disabled:cursor-not-allowed',
            props.class
          )
        "
        @focus="active = true"
        @blur="active = false"
      />
    </template>
    
    components/input-otp/index.ts
    import type { ComputedRef, InjectionKey } from 'vue';
    
    export { default as InputOTP, type InputOTPProps } from './InputOTP.vue';
    export {
      default as InputOTPGroup,
      type InputOTPGroupProps,
    } from './InputOTPGroup.vue';
    export {
      default as InputOTPSeparator,
      type InputOTPSeparatorProps,
    } from './InputOTPSeparator.vue';
    export {
      default as InputOTPSlot,
      type InputOTPSlotProps,
    } from './InputOTPSlot.vue';
    
    export const InputOTPKey: InjectionKey<
      ComputedRef<{
        'aria-label'?: unknown;
        'aria-labelledby'?: unknown;
        'aria-invalid'?: unknown;
      }>
    > = Symbol('input-otp');
    
    lib/icons.ts
    /**
     * Internal Icons
     *
     * This barrel file re-exports icons used internally by the design system.
     * These are hardcoded icons for component states (loading, validation, navigation).
     *
     * User-facing icons should be passed via props by consumers.
     *
     * This set draws Phosphor. `nxds init --icons` installs the same names drawn
     * by another library.
     */
    import { PhCircle } from '@phosphor-icons/vue';
    import { type FunctionalComponent, h } from 'vue';
    
    // Loading states
    export { PhCircleNotch as IconLoader2 } from '@phosphor-icons/vue';
    
    // Navigation
    export {
      PhArrowDown as IconArrowDown,
      PhArrowLeft as IconArrowLeft,
      PhArrowRight as IconArrowRight,
      PhArrowUp as IconArrowUp,
      PhCaretDown as IconChevronDown,
      PhCaretLeft as IconChevronLeft,
      PhCaretRight as IconChevronRight,
      PhCaretUp as IconChevronUp,
      PhDotsThree as IconDots,
      PhSidebarSimple as IconLayoutSidebar,
      PhCaretUpDown as IconSelector,
    } from '@phosphor-icons/vue';
    
    // Actions
    export {
      PhCheck as IconCheck,
      PhCopy as IconCopy,
      PhMinus as IconMinus,
      PhPencilSimple as IconPencil,
      PhPlus as IconPlus,
      PhX as IconX,
    } from '@phosphor-icons/vue';
    
    export const IconCircleFilled: FunctionalComponent = (_props, { attrs }) =>
      h(PhCircle, { weight: 'fill', ...attrs });
    
    // Filter field types
    export {
      PhCalendarBlank as IconCalendar,
      PhHash as IconHash,
      PhTextAa as IconLetterCase,
      PhListBullets as IconList,
    } from '@phosphor-icons/vue';
    
    // Search / input
    export { PhMagnifyingGlass as IconSearch } from '@phosphor-icons/vue';
    
    // Alerts/Status
    export {
      PhWarningCircle as IconAlertCircle,
      PhWarning as IconAlertTriangle,
      PhCheckCircle as IconCircleCheck,
      PhInfo as IconInfoCircle,
    } from '@phosphor-icons/vue';
    
    // Layout
    export {
      PhDotsSixVertical as IconGripVertical,
      PhPalette as IconPalette,
    } from '@phosphor-icons/vue';
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/input-otp';

Examples

With separator

Split a six-digit code into two groups of three with a separator.

<script setup lang="ts">
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from '@/components/input-otp';
</script>

<template>
  <InputOTP aria-label="One-time password">
    <InputOTPGroup>
      <InputOTPSlot :index="0" />
      <InputOTPSlot :index="1" />
      <InputOTPSlot :index="2" />
    </InputOTPGroup>
    <InputOTPSeparator />
    <InputOTPGroup>
      <InputOTPSlot :index="3" />
      <InputOTPSlot :index="4" />
      <InputOTPSlot :index="5" />
    </InputOTPGroup>
  </InputOTP>
</template>

Disabled

Dim every slot and block input while the field is disabled.

<script setup lang="ts">
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/input-otp';
</script>

<template>
  <InputOTP disabled aria-label="One-time password">
    <InputOTPGroup>
      <InputOTPSlot :index="0" />
      <InputOTPSlot :index="1" />
      <InputOTPSlot :index="2" />
      <InputOTPSlot :index="3" />
      <InputOTPSlot :index="4" />
      <InputOTPSlot :index="5" />
    </InputOTPGroup>
  </InputOTP>
</template>

Props

InputOTP

InputOTP props
PropTypeDefaultDescription
defaultValuestringNo default documentedNo description
modelValuestringNo default documentedNo description

InputOTPGroup

InputOTPGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

InputOTPSeparator

InputOTPSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

InputOTPSlot

InputOTPSlot props
PropTypeDefaultDescription
indexRequirednumberNo default documented

Position of the slot in the code, starting at 0.