Pin Input

Pin Input is a component for entering short sequences of numbers or characters, commonly used for verification codes or PINs.

Examples

Basic

PropTypeDefaultDescription
defaultValuePinInputValue<Type>-Sets the initial values of the pin inputs when rendered. Use this for uncontrolled pin input state.
disabledboolean-Disables the pin input, preventing user interaction when set to true.
maxLengthnumber-Specifies the number of input fields for the pin input, determining the required PIN or code length.
maskboolean-Masks the pin input values as password fields when enabled.
modelValuePinInputValue<Type> | null-Controls the value of the pin input. Supports two-way binding with v-model.
otpboolean-Enables OTP auto-detection and autocomplete on supported mobile devices when set to true.
placeholderstring-Specifies the placeholder character to display in empty pin input fields.
typeTypetextSets the input type for the pin input fields.
groupBynumber0Specifies the number of input fields to group together.
Preview
Code

Variant & Color

PropDefaultTypeDescription
pin-inputoutline-primary{variant}-{color}Controls the visual style of the pin input.
VariantDescription
outlineThe default variant.
solidThe solid variant.
~The unstyle or base variant
Preview
Code

Size

PropDefaultTypeDescription
sizemdstringAllows you to change the size of the pin-input.

🚀 Adjust pin-input size freely using any size, breakpoints (e.g., sm:sm, xs:lg), or states (e.g., hover:lg, focus:3xl).

Preview
Code

Separator

PropDefaultTypeDescription
separatorfalseboolean | stringControls separators between input groups: false for none, true for default icon (minus), or provide a custom icon name.
Preview
Code

Form Field

The NPinInput component can be easily embedded within the NFormField component.

Preview
Code

Enter your pin

Slots

NamePropsDescription
default-Allows advanced customization using sub-components, replacing the default pin-input structure.
group-Replaces the entire group container, including input and separator.
slot-Custom input slot
separator-Custom separator for the pin-input.

Custom Rendering

Use the default slot for complete control over the pin input's structure. This allows you to compose the pin input using its individual sub-components (such as input and separator elements, as listed in the Slots section), similar to approaches used in libraries like shadcn/ui.

Preview
Code

Presets

shortcuts/pin-input.ts
type PinInputPrefix = 'pin-input'

export const staticPinInput: Record<`${PinInputPrefix}-${string}` | PinInputPrefix, string> = {
  // configurations
  'pin-input': 'flex items-center gap-2 has-disabled:opacity-50 disabled:cursor-not-allowed',
  'pin-input-separator-icon': 'i-lucide-minus',

  // components
  'pin-input-slot': 'relative flex square-2.5714285714285716em items-center justify-center bg-transparent shadow-sm border-y border-r first:rounded-l-md first:border-l last:rounded-r-md text-0.875em leading-1.4285714285714286em transition-all outline-none text-center',
  'pin-input-group': 'flex items-center',
  'pin-input-separator': 'grid',
}

export const dynamicPinInput: [RegExp, (params: RegExpExecArray) => string][] = [
  [/^pin-input-focus(-(\S+))?$/, ([, , c = 'primary']) => `focus-visible:ring-${c}-500 dark:focus-visible:ring-${c}-400 focus:z-10 focus:ring-1`],

  // dynamic preset
  [/^pin-input-outline(-(\S+))?$/, ([, , c = 'primary']) => `border-input  pin-input-focus-${c}`],
  [/^pin-input-solid(-(\S+))?$/, ([, , c = 'primary']) => `border-${c}-500 focus:border dark:border-${c}-400 pin-input-focus-${c}`],
]

export const pinInput = [
  ...dynamicPinInput,
  staticPinInput,
]

Props

types/pin-input.ts
import type { PinInputInputProps, PinInputRootProps, PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'

interface BaseExtensions {
  /** CSS class for the component */
  class?: HTMLAttributes['class']
  /** Size of the component */
  size?: HTMLAttributes['class']
}

export type PinInputType = 'text' | 'number'
export type PinInputValueType<T extends PinInputType> = T extends 'text' ? string : number

export interface NPinInputProps<T extends PinInputType = 'text'> extends Omit<PinInputRootProps<T>, 'defaultValue'>, Pick<NPinInputSlotProps, 'pinInput' | 'status'>, BaseExtensions {
  /**
   * The default value of the pin input.
   * @default []
   */
  defaultValue?: PinInputValueType<T>[]
  /**
   * The maximum number of slots to render.
   */
  maxLength?: number
  /**
   * The icon to use as a separator between pin-input groups.
   */
  separator?: boolean | string
  /**
   * The number of slots to group together.
   *
   * @default 0
   */
  groupBy?: number
  /** Props for the pin input group */
  _pinInputGroup?: Partial<NPinInputGroupProps>
  /** Props for the pin input separator */
  _pinInputSeparator?: Partial<NPinInputSeparatorProps>
  /** Props for the pin input slots */
  _pinInputSlot?: Partial<NPinInputSlotProps>
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/pin-input.ts
   */
  una?: NPinInputUnaProps
}

export interface NPinInputGroupProps extends PrimitiveProps, BaseExtensions {
  /** Additional properties for the una component */
  una?: Pick<NPinInputUnaProps, 'pinInputGroup'>
}

export interface NPinInputSlotProps extends PinInputInputProps, Pick<NPinInputSeparatorProps, 'icon'>, BaseExtensions {
  /**
   * Allows you to add `UnaUI` pin-input preset properties,
   * Think of it as a shortcut for adding options or variants to the preset if available.
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/pin-input.ts
   * @example
   * pin-input="outline-indigo"
   */
  pinInput?: HTMLAttributes['class']
  /**
   * Update the pin input status.
   * Useful for validations.
   *
   * @default null
   */
  status?: 'info' | 'success' | 'warning' | 'error'
  /** Additional properties for the una component */
  una?: Pick<NPinInputUnaProps, 'pinInputSlot'>
}

export interface NPinInputSeparatorProps extends PrimitiveProps, BaseExtensions {
  /**
   * The separator to use between pin input fields.
   *@example
   * 'i-lucide:dot'
   */
  icon?: string
  /** Additional properties for the una component */
  una?: Pick<NPinInputUnaProps, 'pinInputSeparator'>
}

interface NPinInputUnaProps {
  /** CSS class for the pin input root */
  pinInput?: HTMLAttributes['class']
  /** CSS class for the pin input group */
  pinInputGroup?: HTMLAttributes['class']
  /** CSS class for the pin input separator */
  pinInputSeparator?: HTMLAttributes['class']
  /** CSS class for the pin input slots */
  pinInputSlot?: HTMLAttributes['class']
}

Components

PinInput.vue
PinInputGroup.vue
PinInputSlot.vue
PinInputSeparator.vue
<script lang="ts">
import type { NPinInputProps, PinInputType } from '../../types'
</script>

<script setup lang="ts" generic="T extends PinInputType = 'text'">
import type { PinInputRootEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { PinInputRoot, useForwardPropsEmits } from 'reka-ui'
import { computed } from 'vue'
import { cn, randomId } from '../../utils'
import PinInputGroup from './PinInputGroup.vue'
import PinInputSeparator from './PinInputSeparator.vue'
import PinInputSlot from './PinInputSlot.vue'

const props = withDefaults(defineProps<NPinInputProps<T>>(), {
  type: 'text' as never,
  pinInput: 'outline-primary',
  groupBy: 0,
  size: 'md',
  separator: false,
})

const emits = defineEmits<PinInputRootEmits>()

const rootProps = reactivePick(props, [
  'as',
  'asChild',
  'dir',
  'defaultValue',
  'disabled',
  'id',
  'mask',
  'modelValue',
  'name',
  'otp',
  'placeholder',
  'type',
  'required',
])

const forwarded = useForwardPropsEmits(rootProps, emits)

const id = computed(() => props.id ?? randomId('pin-input'))

const separator = computed(() => {
  if (props.separator === true) {
    return 'pin-input-separator-icon'
  }

  return props.separator
})

const maxLength = computed(() => {
  if (typeof props.maxLength !== 'number') {
    return props.modelValue?.length ?? 0
  }

  return props.maxLength
})
</script>

<template>
  <PinInputRoot
    v-bind="forwarded"
    :id
    data-slot="pin-input"
    :class="cn(
      'pin-input',
      props.una?.pinInput,
      props.class,
    )"
    :size
  >
    <slot>
      <template v-if="groupBy === 0">
        <PinInputGroup
          v-bind="_pinInputGroup"
          :una
        >
          <slot name="group">
            <template v-for="index in maxLength" :key="index">
              <slot name="slot" :index="index - 1">
                <PinInputSlot
                  :index="index - 1"
                  :una
                  :status
                  :pin-input
                  v-bind="_pinInputSlot"
                />
              </slot>
            </template>
          </slot>
        </PinInputGroup>
      </template>

      <template v-else>
        <template v-for="(groupIndex) in Math.ceil(maxLength / groupBy)" :key="groupIndex">
          <PinInputGroup
            v-bind="_pinInputGroup"
            :una
          >
            <slot name="group">
              <template v-for="slotInGroup in Math.min(groupBy, maxLength - (groupIndex - 1) * groupBy)" :key="slotInGroup">
                <slot name="slot" :index="(groupIndex - 1) * groupBy + slotInGroup - 1">
                  <PinInputSlot
                    :index="(groupIndex - 1) * groupBy + slotInGroup - 1"
                    :una
                    :pin-input
                    :status
                    v-bind="_pinInputSlot"
                  />
                </slot>
              </template>
            </slot>
          </PinInputGroup>

          <template v-if="separator !== false && (separator || $slots.separator) && groupIndex < Math.ceil(maxLength / groupBy)">
            <PinInputSeparator
              :icon="separator"
              :una
              v-bind="_pinInputSeparator"
            >
              <slot name="separator" />
            </PinInputSeparator>
          </template>
        </template>
      </template>
    </slot>
  </PinInputRoot>
</template>