UIPackage

Pin Input

React form
Edit on GitHub

One-time-code input — N separate boxes that auto-advance and accept paste. Use for SMS verification, 2FA, and short numeric codes. Length, masking, and per-slot status all configurable.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/pin-input.json

Or with the named registry: npx shadcn@latest add @uipkge-react/pin-input

Examples

Props

Name Type / Values Default Required
maxLength

Number of slots. Maps to input-otp's `maxLength`.

number 6 optional
mask

Render the typed characters as dots instead of plain text.

boolean false optional
status PinInputStatus default optional
size PinInputSize md optional
onComplete

Fires with the joined string once every slot is filled.

(value: string) => void optional
children React.ReactNode optional
className string optional
containerClassName string optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

PinInputContextValue
interface PinInputContextValue {
  mask: boolean
  status: PinInputStatus
  size: PinInputSize
}

Dependencies

Used by

Files (2)

  • components/ui/pin-input/pin-input.tsx 4.6 kB
    'use client'
    
    import * as React from 'react'
    import { OTPInput, OTPInputContext } from 'input-otp'
    import { Minus } from 'lucide-react'
    import { cn } from '@/lib/utils'
    
    type PinInputStatus = 'error' | 'warning' | 'success' | 'default'
    type PinInputSize = 'sm' | 'md' | 'lg'
    
    interface PinInputContextValue {
      mask: boolean
      status: PinInputStatus
      size: PinInputSize
    }
    
    const PinInputUiContext = React.createContext<PinInputContextValue>({
      mask: false,
      status: 'default',
      size: 'md',
    })
    
    export interface PinInputProps
      extends Omit<React.ComponentPropsWithoutRef<typeof OTPInput>, 'render' | 'children' | 'maxLength' | 'size'> {
      /** Number of slots. Maps to input-otp's `maxLength`. */
      maxLength?: number
      /** Render the typed characters as dots instead of plain text. */
      mask?: boolean
      status?: PinInputStatus
      size?: PinInputSize
      /** Fires with the joined string once every slot is filled. */
      onComplete?: (value: string) => void
      children?: React.ReactNode
      className?: string
      containerClassName?: string
    }
    
    const PinInput = React.forwardRef<React.ElementRef<typeof OTPInput>, PinInputProps>(
      (
        {
          className,
          containerClassName,
          maxLength = 6,
          mask = false,
          status = 'default',
          size = 'md',
          onComplete,
          children,
          ...props
        },
        ref,
      ) => {
        return (
          <PinInputUiContext.Provider value={{ mask, status, size }}>
            <OTPInput
              ref={ref}
              data-uipkge=""
              data-slot="pin-input"
              maxLength={maxLength}
              onComplete={onComplete}
              containerClassName={cn(
                'flex items-center gap-2 has-disabled:opacity-50',
                containerClassName,
              )}
              className={cn('disabled:cursor-not-allowed', className)}
              {...props}
            >
              {children}
            </OTPInput>
          </PinInputUiContext.Provider>
        )
      },
    )
    PinInput.displayName = 'PinInput'
    
    const PinInputGroup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
      ({ className, ...props }, ref) => (
        <div
          ref={ref}
          data-uipkge=""
          data-slot="pin-input-group"
          className={cn('flex items-center', className)}
          {...props}
        />
      ),
    )
    PinInputGroup.displayName = 'PinInputGroup'
    
    const sizeClassMap: Record<PinInputSize, string> = {
      sm: 'h-8 w-8 text-sm',
      lg: 'h-12 w-12 text-xl',
      md: 'h-10 w-10 text-base',
    }
    
    const statusClassMap: Record<PinInputStatus, string> = {
      error: 'border-destructive focus-within:border-destructive focus-within:ring-destructive/40 text-destructive',
      warning: 'border-warning focus-within:border-warning focus-within:ring-warning/40 text-warning',
      success: 'border-success focus-within:border-success focus-within:ring-success/40 text-success',
      default: '',
    }
    
    export interface PinInputSlotProps extends React.ComponentPropsWithoutRef<'div'> {
      index: number
      /** Override the inherited mask flag for this slot. */
      mask?: boolean
    }
    
    const PinInputSlot = React.forwardRef<HTMLDivElement, PinInputSlotProps>(
      ({ index, mask: maskProp, className, ...props }, ref) => {
        const ui = React.useContext(PinInputUiContext)
        const inputContext = React.useContext(OTPInputContext)
        const slot = inputContext.slots[index]
        const char = slot?.char ?? null
        const isActive = slot?.isActive ?? false
        const effectiveMask = maskProp ?? ui.mask
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="pin-input-slot"
            data-active={isActive ? '' : undefined}
            className={cn(
              'border-input bg-background text-foreground relative -ml-px flex items-center justify-center border text-center shadow-xs transition-[border-color,box-shadow] outline-none first:ml-0 first:rounded-l-md last:rounded-r-md',
              'focus-within:border-ring focus-within:ring-ring/40 focus-within:relative focus-within:z-10 focus-within:ring-2',
              'disabled:cursor-not-allowed disabled:opacity-50',
              isActive && 'border-ring ring-ring/40 z-10 ring-2',
              sizeClassMap[ui.size],
              statusClassMap[ui.status],
              className,
            )}
            {...props}
          >
            {char != null && (effectiveMask ? <span className="bg-foreground size-2 rounded-full" /> : char)}
          </div>
        )
      },
    )
    PinInputSlot.displayName = 'PinInputSlot'
    
    const PinInputSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
      ({ children, ...props }, ref) => (
        <div ref={ref} data-uipkge="" data-slot="pin-input-separator" role="separator" {...props}>
          {children ?? <Minus />}
        </div>
      ),
    )
    PinInputSeparator.displayName = 'PinInputSeparator'
    
    export { PinInput, PinInputGroup, PinInputSlot, PinInputSeparator }
  • components/ui/pin-input/index.ts 0.1 kB
    export {
      PinInput,
      PinInputGroup,
      PinInputSlot,
      PinInputSeparator,
      type PinInputProps,
      type PinInputSlotProps,
    } from './pin-input'

Raw manifest: https://react.uipkge.dev/r/react/pin-input.json