
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
inputuiText input — single-line. Three sizes, three variants (outlined / filled / borderless), error / warning status, prefix / suffix (string, icon, or slot), addonBefore / addonAfter, allow-clear, password toggle, char count, and composite InputGroup with addons and action buttons.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/input.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/input.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/input.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/input.jsonnpx shadcn-vue@latest add @uipkge/inputInstalls to:app/components/ui/input/| Name | Type / Values | Default | Required |
|---|---|---|---|
defaultValue | string | number | — | optional |
modelValue | string | number | — | optional |
class | HTMLAttributes['class'] | — | optional |
size | 'small''middle''large' | 'middle' | optional |
variant | 'outlined''filled''borderless' | 'outlined' | optional |
status | 'error''warning' | — | optional |
prefix | string | — | optional |
suffix | string | — | optional |
prefixIconIf both `prefix` (string) and `prefixIcon` are set, the icon wins. | Component | — | optional |
suffixIcon | Component | — | optional |
addonBefore | string | — | optional |
addonAfter | string | — | optional |
allowClear | boolean | — | optional |
showCount | boolean | — | optional |
showPasswordToggle | boolean | — | optional |
disabled | boolean | — | optional |
readonly | boolean | — | optional |
maxlength | number | string | — | optional |
minlength | number | string | — | optional |
type | string | 'text' | optional |
placeholder | string | — | optional |
id | string | — | optional |
<script setup lang="ts">
import type { Component, HTMLAttributes } from 'vue'
import { computed, ref, useAttrs, useSlots } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { X, Eye, EyeOff } from 'lucide-vue-next'
defineOptions({
inheritAttrs: false,
})
interface Props {
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
size?: 'small' | 'middle' | 'large'
variant?: 'outlined' | 'filled' | 'borderless'
status?: 'error' | 'warning'
prefix?: string
suffix?: string
// Convenience props for the very common "icon at the start/end" case.
// Pass a lucide-vue-next (or any) component: `:prefix-icon="Mail"`.
// If both `prefix` (string) and `prefixIcon` are set, the icon wins.
prefixIcon?: Component
suffixIcon?: Component
addonBefore?: string
addonAfter?: string
allowClear?: boolean
showCount?: boolean
showPasswordToggle?: boolean
disabled?: boolean
readonly?: boolean
maxlength?: number | string
minlength?: number | string
type?: string
placeholder?: string
id?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 'middle',
variant: 'outlined',
type: 'text',
})
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const slots = useSlots()
const attrs = useAttrs()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
const inputRef = ref<HTMLInputElement | null>(null)
const focused = ref(false)
const hovered = ref(false)
const passwordVisible = ref(false)
const isPassword = computed(() => props.type === 'password')
const hasPrefix = computed(() => !!props.prefix || !!props.prefixIcon || !!slots.prefix)
const hasSuffix = computed(() => !!props.suffix || !!props.suffixIcon || !!slots.suffix)
const hasAddonBefore = computed(() => !!props.addonBefore || !!slots.addonBefore)
const hasAddonAfter = computed(() => !!props.addonAfter || !!slots.addonAfter)
const hasRightConfig = computed(() => {
const hasCount = props.showCount && props.maxlength != null
const hasPasswordToggle = props.showPasswordToggle && isPassword.value
return props.allowClear || hasPasswordToggle || hasCount
})
const hasRight = computed(() => hasSuffix.value || hasRightConfig.value)
const currentLength = computed(() => String(modelValue.value ?? '').length)
const showClear = computed(() => {
return (
props.allowClear && !!modelValue.value && (focused.value || hovered.value) && !props.disabled && !props.readonly
)
})
const showPasswordToggleBtn = computed(() => {
return isPassword.value && props.showPasswordToggle && !props.disabled && !props.readonly
})
const showCountDisplay = computed(() => {
return props.showCount && props.maxlength != null
})
const computedType = computed(() => {
if (!isPassword.value) return props.type
return passwordVisible.value ? 'text' : 'password'
})
const sizeClasses = {
small: 'h-8 text-xs',
middle: 'h-9 text-base md:text-sm',
large: 'h-11 text-base',
}
const wrapperRounded = computed(() => {
if (hasAddonBefore.value && hasAddonAfter.value) return 'rounded-none'
if (hasAddonBefore.value) return 'rounded-l-none rounded-r-md'
if (hasAddonAfter.value) return 'rounded-r-none rounded-l-md'
return 'rounded-md'
})
const resolvedAriaInvalid = computed(() => {
if (props.status === 'error') return true as const
const raw = attrs['aria-invalid']
if (raw === true || raw === 'true' || raw === '' || raw === 'grammar' || raw === 'spelling') {
return raw === '' ? true : (raw as boolean | 'true' | 'false' | 'grammar' | 'spelling')
}
if (raw === false || raw === 'false') return false as const
return undefined
})
const wrapperClasses = computed(() => {
const base = 'flex w-full items-center gap-1.5 overflow-hidden border transition-[color,box-shadow] outline-none'
const sizeClass = sizeClasses[props.size]
const variantMap = {
outlined: 'border-input bg-transparent shadow-xs',
filled: 'border-transparent bg-muted/50 shadow-none',
borderless: 'border-transparent bg-transparent shadow-none',
}
const variantClass = variantMap[props.variant]
const statusMap = {
error:
'border-destructive focus-within:border-destructive focus-within:ring-destructive/20 dark:focus-within:ring-destructive/40',
warning: 'border-warning focus-within:border-warning focus-within:ring-warning/20',
}
const statusClass = props.status ? statusMap[props.status] : ''
const focusClass = !props.status ? 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]' : ''
const disabledClass = props.disabled ? 'pointer-events-none opacity-50 cursor-not-allowed bg-muted/30' : ''
const ariaInvalidClass =
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive'
return cn(
base,
sizeClass,
variantClass,
statusClass,
focusClass,
disabledClass,
ariaInvalidClass,
wrapperRounded.value,
props.class,
)
})
function addonClasses(position: 'before' | 'after') {
const roundedClass =
position === 'before' ? 'rounded-l-md rounded-r-none border-r-0' : 'rounded-r-md rounded-l-none border-l-0'
return cn(
'flex items-center bg-muted px-3 text-sm text-muted-foreground border border-input',
roundedClass,
sizeClasses[props.size],
)
}
const inputPadding = computed(() => {
const hasLeft = hasPrefix.value
const right = hasRight.value
const leftPad = props.size === 'small' ? 'pl-2' : props.size === 'large' ? 'pl-3' : 'pl-2.5'
const rightPad = props.size === 'small' ? 'pr-2' : props.size === 'large' ? 'pr-3' : 'pr-2.5'
if (!hasLeft && !right) return cn(leftPad, rightPad)
if (hasLeft && !right) return cn('pl-0', rightPad)
if (!hasLeft && right) return cn(leftPad, 'pr-0')
return 'px-0'
})
function handleClear() {
modelValue.value = ''
inputRef.value?.focus()
}
function togglePassword() {
passwordVisible.value = !passwordVisible.value
inputRef.value?.focus()
}
</script>
<template>
<div class="flex w-full">
<!-- Addon before -->
<div v-if="hasAddonBefore" :class="addonClasses('before')">
<slot name="addonBefore">{{ addonBefore }}</slot>
</div>
<!-- Input wrapper -->
<div
:class="wrapperClasses"
data-uipkge
data-slot="input"
:aria-invalid="resolvedAriaInvalid"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
@click="inputRef?.focus()"
>
<!-- Prefix -->
<span
v-if="hasPrefix"
class="text-muted-foreground pointer-events-none shrink-0 select-none"
:class="props.size === 'small' ? 'pl-2' : props.size === 'large' ? 'pl-3' : 'pl-2.5'"
>
<slot name="prefix">
<component :is="prefixIcon" v-if="prefixIcon" class="size-4" aria-hidden="true" />
<template v-else>{{ prefix }}</template>
</slot>
</span>
<!-- Native input -->
<input
:id="id"
ref="inputRef"
v-model="modelValue"
v-bind="attrs"
:type="computedType"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:minlength="minlength"
:placeholder="placeholder"
:aria-invalid="resolvedAriaInvalid"
:class="
cn(
'w-full min-w-0 flex-1 bg-transparent outline-none',
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
'disabled:cursor-not-allowed',
inputPadding,
)
"
@focus="focused = true"
@blur="focused = false"
/>
<!-- Suffix / Actions -- only render when there is something on the
right; otherwise an empty padded flex child steals horizontal
space from plain inputs. Built-in actions (clear / password
toggle / count) render first, then the user's suffix so the
slotted content is always the rightmost element in the row. -->
<div
v-if="hasRight"
class="flex shrink-0 items-center gap-1"
:class="props.size === 'small' ? 'pr-2' : props.size === 'large' ? 'pr-3' : 'pr-2.5'"
>
<button
v-if="showClear"
type="button"
aria-label="Clear input"
class="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 shrink-0 rounded p-0.5 transition-colors focus-visible:ring-1 focus-visible:outline-none"
@mousedown.prevent="handleClear"
@click="handleClear"
>
<X class="size-4" aria-hidden="true" />
</button>
<button
v-if="showPasswordToggleBtn"
type="button"
:aria-label="passwordVisible ? 'Hide password' : 'Show password'"
:aria-pressed="passwordVisible"
class="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 shrink-0 rounded p-0.5 transition-colors focus-visible:ring-1 focus-visible:outline-none"
@mousedown.prevent="togglePassword"
@click="togglePassword"
>
<EyeOff v-if="passwordVisible" class="size-4" aria-hidden="true" />
<Eye v-else class="size-4" aria-hidden="true" />
</button>
<span v-if="showCountDisplay" class="text-muted-foreground pointer-events-none text-xs select-none">
{{ currentLength }}/{{ maxlength }}
</span>
<span v-if="hasSuffix" class="text-muted-foreground pointer-events-none select-none">
<slot name="suffix">
<component :is="suffixIcon" v-if="suffixIcon" class="size-4" aria-hidden="true" />
<template v-else>{{ suffix }}</template>
</slot>
</span>
</div>
</div>
<!-- Addon after -->
<div v-if="hasAddonAfter" :class="addonClasses('after')">
<slot name="addonAfter">{{ addonAfter }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
size?: 'small' | 'middle' | 'large'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'middle',
})
const sizeClasses: Record<string, string> = {
small: 'h-8 text-xs',
middle: 'h-9 text-sm',
large: 'h-11 text-base',
}
</script>
<template>
<div
data-uipkge
data-slot="input-group"
:data-size="size"
:data-disabled="disabled ? '' : undefined"
:class="
cn(
'group/input-group border-input bg-background relative flex w-full items-stretch rounded-md border shadow-xs transition-[color,box-shadow]',
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none',
'[&_[data-slot=input]]:rounded-none [&_[data-slot=input]]:border-0 [&_[data-slot=input]]:bg-transparent [&_[data-slot=input]]:shadow-none [&_[data-slot=input]]:focus-within:ring-0',
'[&_input]:h-full [&_input]:flex-1 [&_input]:border-0 [&_input]:bg-transparent [&_input]:px-3 [&_input]:text-sm [&_input]:outline-none [&_input]:focus-visible:ring-0',
disabled && 'bg-muted/30 pointer-events-none cursor-not-allowed opacity-50',
sizeClasses[size],
props.class,
)
"
>
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
align?: 'inline' | 'block'
}
const props = withDefaults(defineProps<Props>(), {
align: 'inline',
})
</script>
<template>
<div
data-uipkge
data-slot="input-group-addon"
:class="
cn(
'text-muted-foreground flex shrink-0 items-center justify-center px-3 text-sm select-none',
'border-input first:rounded-l-[calc(var(--radius)-1px)] last:rounded-r-[calc(var(--radius)-1px)]',
'border-r first:border-l-0 last:border-r-0',
props.class,
)
"
>
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
variant?: 'default' | 'secondary' | 'ghost' | 'outline'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'ghost',
})
const variantClasses: Record<string, string> = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
outline: 'border-l border-input hover:bg-accent hover:text-accent-foreground',
}
</script>
<template>
<button
type="button"
data-uipkge
data-slot="input-group-button"
:disabled="disabled"
:class="
cn(
'inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 px-3 text-sm font-medium transition-colors select-none',
'first:rounded-l-[calc(var(--radius)-1px)] last:rounded-r-[calc(var(--radius)-1px)]',
'focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50',
variantClasses[variant],
props.class,
)
"
>
<slot />
</button>
</template>
export { default as Input } from './Input.vue'
export { default as InputGroup } from './InputGroup.vue'
export { default as InputGroupAddon } from './InputGroupAddon.vue'
export { default as InputGroupButton } from './InputGroupButton.vue'
Raw manifest:https://uipkge.dev/r/vue/input.json