Pin Input
pin-input ui 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 React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/pin-input.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/pin-input.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/pin-input.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/pin-input.json Named registry:
npx shadcn-vue@latest add @uipkge/pin-input Installs to: app/components/ui/pin-input/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
mask | boolean | false | optional |
autoSubmit | boolean | false | optional |
status | PinInputStatus | 'default' | optional |
size | PinInputSize | 'md' | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
PinInputContext interface PinInputContext {
mask: Ref<boolean>
status: Ref<PinInputStatus>
size: Ref<PinInputSize>
} npm dependencies
Used by
Files installed (5)
-
app/components/ui/pin-input/PinInput.vue 3.1 kB
<script setup lang="ts" generic="Type extends 'text' | 'number' = 'text'"> import { nextTick, provide, ref, toRef, watch } from 'vue' import type { PinInputRootEmits, PinInputRootProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { PinInputRoot, useForwardPropsEmits } from 'reka-ui' import { cn } from '@/lib/utils' type PinInputStatus = 'error' | 'warning' | 'success' | 'default' type PinInputSize = 'sm' | 'md' | 'lg' const props = withDefaults( defineProps< PinInputRootProps<Type> & { class?: HTMLAttributes['class'] mask?: boolean autoSubmit?: boolean status?: PinInputStatus size?: PinInputSize } >(), { otp: true, mask: false, autoSubmit: false, status: 'default', size: 'md', }, ) const emits = defineEmits< Omit<PinInputRootEmits<Type>, 'complete'> & { complete: [value: string] } >() const delegatedProps = reactiveOmit(props, 'class', 'mask', 'autoSubmit', 'status', 'size') const forwarded = useForwardPropsEmits(delegatedProps, emits) provide('pinInputContext', { mask: toRef(props, 'mask'), status: toRef(props, 'status'), size: toRef(props, 'size'), }) function handleComplete(value: string[]) { const joined = value.join('') emits('complete', joined) if (props.autoSubmit) { const event = new CustomEvent('pin-submit', { detail: joined, bubbles: true }) document.dispatchEvent(event) } } // One-shot shake when status transitions into error (not on mount / not continuous). const isShaking = ref(false) const isFirstStatus = ref(true) watch( () => props.status, (next, prev) => { // immediate:true so mount-time status="error" does not shake. if (isFirstStatus.value) { isFirstStatus.value = false return } if (next === 'error' && prev !== 'error') { isShaking.value = false nextTick(() => { isShaking.value = true }) } }, { immediate: true }, ) function onShakeEnd(event: AnimationEvent) { if (event.animationName === 'pin-input-shake') { isShaking.value = false } } </script> <template> <PinInputRoot :otp="props.otp" data-uipkge data-slot="pin-input" :data-status="props.status === 'default' ? undefined : props.status" v-bind="forwarded" :class=" cn( 'flex items-center gap-2 disabled:cursor-not-allowed has-disabled:opacity-50', isShaking && 'pin-input-shake', props.class, ) " @complete="handleComplete" @animationend="onShakeEnd" > <slot /> </PinInputRoot> </template> <style> @keyframes pin-input-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-5px); } 40% { transform: translateX(5px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } } [data-slot='pin-input'].pin-input-shake { animation: pin-input-shake 380ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both; } @media (prefers-reduced-motion: reduce) { [data-slot='pin-input'].pin-input-shake { animation: none !important; } } </style> -
app/components/ui/pin-input/PinInputGroup.vue 0.6 kB
<script setup lang="ts"> import type { PrimitiveProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { Primitive, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <Primitive data-uipkge data-slot="pin-input-group" v-bind="forwardedProps" :class="cn('flex items-center', props.class)" > <slot /> </Primitive> </template> -
app/components/ui/pin-input/PinInputSeparator.vue 0.5 kB
<script setup lang="ts"> import type { PrimitiveProps } from 'reka-ui' import { Minus } from 'lucide-vue-next' import { Primitive, useForwardProps } from 'reka-ui' const props = defineProps<PrimitiveProps>() const forwardedProps = useForwardProps(props) </script> <template> <Primitive data-uipkge data-slot="pin-input-separator" role="separator" v-bind="forwardedProps"> <slot> <Minus aria-hidden="true" /> </slot> </Primitive> </template> -
app/components/ui/pin-input/PinInputSlot.vue 4 kB
<script setup lang="ts"> import { computed, inject, nextTick, ref, watch } from 'vue' import type { PinInputInputProps } from 'reka-ui' import type { HTMLAttributes, Ref } from 'vue' import { reactiveOmit } from '@vueuse/core' import { injectPinInputRootContext, PinInputInput, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' type PinInputStatus = 'error' | 'warning' | 'success' | 'default' type PinInputSize = 'sm' | 'md' | 'lg' interface PinInputContext { mask: Ref<boolean> status: Ref<PinInputStatus> size: Ref<PinInputSize> } const props = defineProps< PinInputInputProps & { class?: HTMLAttributes['class'] /** Override the inherited mask flag for this slot. */ mask?: boolean } >() const ctx = inject<PinInputContext | null>('pinInputContext', null) const forwarded = useForwardProps(reactiveOmit(props, 'class', 'mask')) const effectiveMask = computed(() => props.mask ?? ctx?.mask.value ?? false) const status = computed(() => ctx?.status.value ?? 'default') const size = computed(() => ctx?.size.value ?? 'md') const inputType = computed(() => (effectiveMask.value ? 'password' : 'text')) const sizeClasses = computed(() => { switch (size.value) { case 'sm': return 'h-8 w-8 text-sm' case 'lg': return 'h-12 w-12 text-xl' default: return 'h-10 w-10 text-base' } }) const statusClasses = computed(() => { switch (status.value) { case 'error': return 'border-destructive focus:border-destructive focus:ring-destructive/40 text-destructive' case 'warning': return 'border-warning focus:border-warning focus:ring-warning/40 text-warning' case 'success': return 'border-success focus:border-success focus:ring-success/40 text-success' default: return '' } }) // Quiet slot pop when a character lands (paste-safe via reka root context). const rekaRoot = injectPinInputRootContext() const slotValue = computed(() => rekaRoot.currentModelValue.value[props.index]) const isPopping = ref(false) const isFirstValue = ref(true) const prevValue = ref<string | number | undefined | null>(undefined) watch( slotValue, (next) => { // immediate:true captures mount value (empty or prefilled) without animating. if (isFirstValue.value) { isFirstValue.value = false prevValue.value = next return } const filled = next !== undefined && next !== null && next !== '' if (filled && next !== prevValue.value) { // Restart animation even on rapid sequential digits. isPopping.value = false nextTick(() => { isPopping.value = true }) } prevValue.value = next }, { immediate: true }, ) function onPopEnd(event: AnimationEvent) { if (event.animationName === 'pin-slot-pop') { isPopping.value = false } } </script> <template> <PinInputInput :type="inputType" data-uipkge data-slot="pin-input-slot" v-bind="forwarded" :class=" cn( 'border-input bg-background text-foreground relative -ml-px flex items-center justify-center border text-center shadow-xs outline-none first:ml-0 first:rounded-l-md last:rounded-r-md', 'transition-[border-color,box-shadow,color,transform] duration-150 ease-out', 'focus:border-ring focus:ring-ring/40 focus:relative focus:z-10 focus:ring-2', 'disabled:cursor-not-allowed disabled:opacity-50', isPopping && 'pin-slot-pop', sizeClasses, statusClasses, props.class, ) " @animationend="onPopEnd" /> </template> <style> /* Quieter than payment-card char pop — whole slot, short overshoot. */ @keyframes pin-slot-pop { 0% { transform: scale(1); } 40% { transform: scale(1.06); } 100% { transform: scale(1); } } [data-slot='pin-input-slot'].pin-slot-pop { animation: pin-slot-pop 200ms cubic-bezier(0.22, 1.25, 0.36, 1) both; z-index: 1; } @media (prefers-reduced-motion: reduce) { [data-slot='pin-input-slot'].pin-slot-pop { animation: none !important; } [data-slot='pin-input-slot'] { transition-duration: 0ms !important; } } </style> -
app/components/ui/pin-input/index.ts 0.2 kB
export { default as PinInput } from './PinInput.vue' export { default as PinInputGroup } from './PinInputGroup.vue' export { default as PinInputSeparator } from './PinInputSeparator.vue' export { default as PinInputSlot } from './PinInputSlot.vue'
Raw manifest: https://uipkge.dev/r/vue/pin-input.json