
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
pin-inputuiOne-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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/pin-inputInstalls to:app/components/ui/pin-input/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
mask | boolean | false | optional |
autoSubmit | boolean | false | optional |
status | 'error''warning''success''default' | 'default' | optional |
size | 'sm''md''lg' | 'md' | optional |
Type aliases from this item's source — use them to shape the data you pass in.
PinInputContextinterface PinInputContext {
mask: Ref<boolean>
status: Ref<PinInputStatus>
size: Ref<PinInputSize>
}<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 | number)[]) {
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>
<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>
<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>
<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>
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