Pin Input
Pin Input is a component for entering short sequences of numbers or characters, commonly used for verification codes or PINs.
Examples
Basic
Prop | Type | Default | Description |
---|---|---|---|
defaultValue | PinInputValue<Type> | - | Sets the initial values of the pin inputs when rendered. Use this for uncontrolled pin input state. |
disabled | boolean | - | Disables the pin input, preventing user interaction when set to true . |
maxLength | number | - | Specifies the number of input fields for the pin input, determining the required PIN or code length. |
mask | boolean | - | Masks the pin input values as password fields when enabled. |
modelValue | PinInputValue<Type> | null | - | Controls the value of the pin input. Supports two-way binding with v-model . |
otp | boolean | - | Enables OTP auto-detection and autocomplete on supported mobile devices when set to true . |
placeholder | string | - | Specifies the placeholder character to display in empty pin input fields. |
type | Type | text | Sets the input type for the pin input fields. |
groupBy | number | 0 | Specifies the number of input fields to group together. |
<script setup lang="ts">
const value = ref(['1', '2', '3', '4', '5', '6'])
</script>
<template>
<div class="flex flex-col flex-wrap gap-6 md:flex-row">
<div class="grid gap-2">
<NLabel for="simple">
Simple
</NLabel>
<NPinInput
id="simple"
:max-length="6"
:group-by="3"
separator
/>
</div>
<div class="grid gap-2">
<NLabel for="digits-only">
Digits Only
</NLabel>
<NPinInput
id="digits-only"
:max-length="6"
type="number"
/>
</div>
<div class="grid gap-2">
<NLabel for="with-separator">
With Separator
</NLabel>
<NPinInput
id="with-separator"
v-model="value"
:max-length="6"
:group-by="3"
separator
/>
</div>
<div class="grid gap-2">
<NLabel for="with-spacing">
With Spacing
</NLabel>
<NPinInput
id="with-spacing"
v-model="value"
:group-by="1"
:max-length="6"
/>
</div>
</div>
</template>
Variant & Color
Prop | Default | Type | Description |
---|---|---|---|
pin-input | outline-primary | {variant}-{color} | Controls the visual style of the pin input. |
Variant | Description |
---|---|
outline | The default variant. |
solid | The solid variant. |
~ | The unstyle or base variant |
<script setup lang="ts">
const value = ref(['1', '2', '', '4', '5', ''])
</script>
<template>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<NPinInput
v-model="value"
placeholder="•"
pin-input="outline-primary"
/>
<NPinInput
v-model="value"
placeholder="•"
pin-input="outline-orange"
/>
<NPinInput
v-model="value"
placeholder="•"
pin-input="outline-amber"
/>
<NPinInput
v-model="value"
placeholder="•"
pin-input="outline-indigo"
/>
<NPinInput
v-model="value"
placeholder="•"
pin-input="solid-lime"
/>
<NPinInput
v-model="value"
placeholder="•"
pin-input="solid-red"
/>
</div>
</template>
Size
Prop | Default | Type | Description |
---|---|---|---|
size | md | string | Allows 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
).
The padding, icons, and text-size of the pin-input scale are dynamically adjusted based on the size property. To customize the text-size and padding simultaneously, you can use utility classes.
<script setup lang="ts">
const value = ref(['1', '2', '3', '4', '5', '6'])
</script>
<template>
<div class="grid grid-cols-1 gap-4 overflow-x-auto sm:grid-cols-2">
<NPinInput
v-model="value"
placeholder="○"
size="xs"
/>
<NPinInput
v-model="value"
placeholder="○"
size="md"
/>
<NPinInput
v-model="value"
placeholder="○"
size="lg"
/>
<NPinInput
v-model="value"
placeholder="○"
size="xl"
/>
<NPinInput
v-model="value"
placeholder="○"
size="2xl"
/>
</div>
</template>
Separator
Prop | Default | Type | Description |
---|---|---|---|
separator | false | boolean | string | Controls separators between input groups: false for none, true for default icon (minus), or provide a custom icon name. |
<script setup lang="ts">
const value = ref(['1', '2', '3', '4', '5', '6'])
</script>
<template>
<div class="grid grid-cols-1 gap-4 overflow-x-auto sm:grid-cols-2">
<NPinInput
v-model="value"
placeholder="○"
separator="i-lucide-minus"
:group-by="3"
/>
<NPinInput
v-model="value"
placeholder="○"
separator="i-ph-star-fill text-warning"
:group-by="3"
/>
<NPinInput
v-model="value"
placeholder="○"
separator="i-ph-circle-fill text-success"
:group-by="3"
/>
<NPinInput
v-model="value"
placeholder="○"
separator="i-ph-triangle-fill text-info"
:group-by="3"
/>
<NPinInput
v-model="value"
placeholder="○"
separator="i-ph-square-fill text-error"
:group-by="3"
/>
</div>
</template>
Form Field
The NPinInput
component can be easily embedded within the NFormField
component.
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const formSchema = toTypedSchema(z.object({
pin: z.array(z.string()).min(6, 'This field is required'),
}))
useForm({
validationSchema: formSchema,
validateOnMount: true,
})
const value = ref<string[]>([])
</script>
<template>
<form>
<NFormField
name="pin"
label="Pin"
description="Enter your pin"
required
>
<NPinInput
v-model="value"
:max-length="6"
:group-by="2"
separator="i-lucide-dot"
/>
</NFormField>
</form>
</template>
Slots
Name | Props | Description |
---|---|---|
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
.
<script setup lang="ts">
const value = ref(['1', '2', '3', '4', '5', '6'])
</script>
<template>
<div class="flex flex-col flex-wrap gap-6 md:flex-row">
<div class="grid gap-2">
<NLabel for="simple">
Simple
</NLabel>
<NPinInput id="simple" :max-length="6">
<NPinInputGroup>
<NPinInputSlot :index="0" />
<NPinInputSlot :index="1" />
<NPinInputSlot :index="2" />
</NPinInputGroup>
<NPinInputSeparator />
<NPinInputGroup>
<NPinInputSlot :index="3" />
<NPinInputSlot :index="4" />
<NPinInputSlot :index="5" />
</NPinInputGroup>
</NPinInput>
</div>
<div class="grid gap-2">
<NLabel for="digits-only">
Digits Only
</NLabel>
<NPinInput id="digits-only" :max-length="6" type="number">
<NPinInputGroup>
<NPinInputSlot :index="0" />
<NPinInputSlot :index="1" />
<NPinInputSlot :index="2" />
<NPinInputSlot :index="3" />
<NPinInputSlot :index="4" />
<NPinInputSlot :index="5" />
</NPinInputGroup>
</NPinInput>
</div>
<div class="grid gap-2">
<NLabel for="with-separator">
With Separator
</NLabel>
<NPinInput
id="with-separator"
v-model="value"
:max-length="6"
>
<NPinInputGroup>
<NPinInputSlot :index="0" />
<NPinInputSlot :index="1" />
</NPinInputGroup>
<NPinInputSeparator />
<NPinInputGroup>
<NPinInputSlot :index="2" />
<NPinInputSlot :index="3" />
</NPinInputGroup>
<NPinInputSeparator />
<NPinInputGroup>
<NPinInputSlot :index="4" />
<NPinInputSlot :index="5" />
</NPinInputGroup>
</NPinInput>
</div>
<div class="grid gap-2">
<NLabel for="with-spacing">
With Spacing
</NLabel>
<NPinInput id="with-spacing" :max-length="6">
<NPinInputGroup class="gap-2 [&>*[data-slot=pin-input-slot]]:border [&>*[data-slot=pin-input-slot]]:rounded-md">
<NPinInputSlot :index="0" aria-invalid="true" />
<NPinInputSlot :index="1" aria-invalid="true" />
<NPinInputSlot :index="2" aria-invalid="true" />
<NPinInputSlot :index="3" aria-invalid="true" />
</NPinInputGroup>
</NPinInput>
</div>
</div>
</template>
Presets
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
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
<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>
<script setup lang="ts">
import type { NPinInputGroupProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { Primitive, useForwardProps } from 'reka-ui'
import { cn } from '../../utils'
const props = defineProps<NPinInputGroupProps>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<Primitive
data-slot="pin-input-group"
v-bind="forwardedProps"
:class="cn(
'pin-input-group',
props.una?.pinInputGroup,
props.class,
)"
>
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { NPinInputSlotProps } from '../../types'
import { reactiveOmit } from '@vueuse/core'
import { PinInputInput, useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '../../utils'
const props = withDefaults(defineProps<NPinInputSlotProps>(), {
pinInput: 'outline-primary',
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const statusClassVariants = computed(() => {
const input = {
info: 'input-status-info pin-input-solid-info input-status-ring',
success: 'input-status-success pin-input-solid-success input-status-ring',
warning: 'input-status-warning pin-input-solid-warning input-status-ring',
error: 'input-status-error pin-input-solid-error input-status-ring',
default: undefined,
}
return {
input: input[props.status ?? 'default'],
}
})
</script>
<template>
<PinInputInput
data-slot="pin-input-slot"
v-bind="forwardedProps"
:class="cn(
'pin-input-slot',
props.una?.pinInputSlot,
props.class,
statusClassVariants.input,
)"
:pin-input="statusClassVariants.input === undefined && props.pinInput"
/>
</template>
<script setup lang="ts">
import type { NPinInputSeparatorProps } from '../../types'
import { Primitive, useForwardProps } from 'reka-ui'
import { cn } from '../../utils'
import Icon from '../elements/Icon.vue'
const props = withDefaults(defineProps<NPinInputSeparatorProps>(), {
icon: 'i-lucide-minus',
})
const forwardedProps = useForwardProps(props)
</script>
<template>
<Primitive
data-slot="pin-input-separator"
v-bind="forwardedProps"
:class="cn(
'pin-input-separator',
props.una?.pinInputSeparator,
props.class,
)"
>
<slot>
<Icon :name="icon" />
</slot>
</Primitive>
</template>