Form
Vue formZod-first form block built on TanStack Vue Form. Wires field labels, descriptions, error messages, and validation together; bind a field once and the rest is automatic.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/form.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/form.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/form.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/form.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/form
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
form | TanstackFormApi | — | required |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
FieldMeta interface FieldMeta {
errors: Array<unknown>
errorMap?: Record<string, Array<unknown> | undefined>
isDirty: boolean
isTouched: boolean
isValid: boolean
} FieldApi interface FieldApi {
name: string
state: {
value: unknown
meta: FieldMeta
}
store: {
subscribe: (cb: () => void) => () => void
}
handleChange: (v: unknown) => void
handleBlur: () => void
} FormFieldContext interface FormFieldContext {
name: string
error: ComputedRef<string | undefined>
valid: ComputedRef<boolean>
isDirty: ComputedRef<boolean>
isTouched: ComputedRef<boolean>
} TanstackFormApi interface TanstackFormApi {
Field: unknown
handleSubmit: () => void | Promise<void>
} Dependencies
Files (15)
-
app/components/ui/form/Form.vue 0.6 kB
<script lang="ts" setup> import type { HTMLAttributes } from 'vue' import { provide } from 'vue' import { cn } from '@/lib/utils' import { FORM_INSTANCE_INJECTION_KEY, type TanstackFormApi } from './injectionKeys' const props = defineProps<{ form: TanstackFormApi class?: HTMLAttributes['class'] }>() provide(FORM_INSTANCE_INJECTION_KEY, props.form) function onSubmit(e: Event) { e.preventDefault() e.stopPropagation() void props.form.handleSubmit() } </script> <template> <form data-uipkge data-slot="form" :class="cn(props.class)" @submit="onSubmit"> <slot /> </form> </template> -
app/components/ui/form/FormActions.vue 0.7 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = withDefaults( defineProps<{ align?: 'left' | 'center' | 'right' gap?: 'sm' | 'md' | 'lg' class?: HTMLAttributes['class'] }>(), { align: 'right', gap: 'md', }, ) const alignClasses = { left: 'justify-start', center: 'justify-center', right: 'justify-end', } const gapClasses = { sm: 'gap-2', md: 'gap-3', lg: 'gap-4', } </script> <template> <div data-uipkge data-slot="form-actions" :class="cn('flex flex-wrap items-center', alignClasses[align], gapClasses[gap], props.class)" > <slot /> </div> </template> -
app/components/ui/form/FormControl.vue 0.4 kB
<script lang="ts" setup> import { Slot } from 'reka-ui' import { useFormField } from './useFormField' const { error, formItemId, formDescriptionId, formMessageId } = useFormField() </script> <template> <Slot :id="formItemId" data-uipkge data-slot="form-control" :aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`" :aria-invalid="!!error" > <slot /> </Slot> </template> -
app/components/ui/form/FormDescription.vue 0.5 kB
<script lang="ts" setup> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { useFormField } from './useFormField' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const { formDescriptionId } = useFormField() </script> <template> <p :id="formDescriptionId" data-uipkge data-slot="form-description" :class="cn('text-muted-foreground text-sm', props.class)" > <slot /> </p> </template> -
app/components/ui/form/FormField.vue 0.9 kB
<script lang="ts" setup> import { computed, inject } from 'vue' import FormFieldInner from './FormFieldInner.vue' import { FORM_INSTANCE_INJECTION_KEY, type TanstackFormApi } from './injectionKeys' const props = defineProps<{ form?: TanstackFormApi name: string validators?: Record<string, unknown> }>() const injected = inject(FORM_INSTANCE_INJECTION_KEY, null) const resolvedForm = computed<TanstackFormApi>(() => { const f = props.form ?? injected if (!f) throw new Error('<FormField> requires a `form` prop or to be nested inside <Form :form="...">') return f }) </script> <template> <component :is="resolvedForm.Field" :name="props.name" :validators="props.validators"> <template #default="{ field }"> <FormFieldInner :field="field"> <template #default="binding"> <slot v-bind="binding" /> </template> </FormFieldInner> </template> </component> </template> -
app/components/ui/form/FormFieldInner.vue 2.4 kB
<script lang="ts" setup> import { computed, onUnmounted, provide, shallowRef, watch } from 'vue' import { FORM_FIELD_INJECTION_KEY } from './injectionKeys' interface FieldMeta { errors: Array<unknown> errorMap?: Record<string, Array<unknown> | undefined> isDirty: boolean isTouched: boolean isValid: boolean } interface FieldApi { name: string state: { value: unknown meta: FieldMeta } store: { subscribe: (cb: () => void) => () => void } handleChange: (v: unknown) => void handleBlur: () => void } const props = defineProps<{ field: FieldApi }>() const snapshot = shallowRef({ value: props.field.state.value, meta: props.field.state.meta }) let unsubscribe: (() => void) | null = null function refreshSnapshot() { snapshot.value = { value: props.field.state.value, meta: props.field.state.meta } } function subscribe(field: FieldApi) { unsubscribe?.() unsubscribe = field.store.subscribe(refreshSnapshot) refreshSnapshot() } subscribe(props.field) watch( () => props.field, (next) => subscribe(next), ) onUnmounted(() => unsubscribe?.()) function formatError(raw: unknown): string { if (typeof raw === 'string') return raw if (raw && typeof raw === 'object' && 'message' in raw) { return String((raw as { message: unknown }).message) } return String(raw) } const error = computed(() => { const meta = snapshot.value.meta const flat = meta.errors if (Array.isArray(flat) && flat.length > 0 && flat[0] != null) { return formatError(flat[0]) } const map = meta.errorMap ?? {} for (const key of Object.keys(map)) { const arr = map[key] if (Array.isArray(arr) && arr.length > 0 && arr[0] != null) { return formatError(arr[0]) } } return undefined }) const valid = computed(() => snapshot.value.meta.isValid) const isDirty = computed(() => snapshot.value.meta.isDirty) const isTouched = computed(() => snapshot.value.meta.isTouched) provide(FORM_FIELD_INJECTION_KEY, { name: props.field.name, error, valid, isDirty, isTouched, }) const componentField = computed(() => ({ modelValue: snapshot.value.value, 'onUpdate:modelValue': (v: unknown) => props.field.handleChange(v), onBlur: () => props.field.handleBlur(), name: props.field.name, })) </script> <template> <slot :field="props.field" :component-field="componentField" :error="error" :valid="valid" :is-dirty="isDirty" :is-touched="isTouched" /> </template> -
app/components/ui/form/FormItem.vue 2.1 kB
<script lang="ts" setup> import type { HTMLAttributes } from 'vue' import { useId } from 'reka-ui' import { computed, provide } from 'vue' import { cn } from '@/lib/utils' import { FORM_ITEM_INJECTION_KEY } from './injectionKeys' import FormLabel from './FormLabel.vue' import type { FormStatus } from './types' const props = defineProps<{ class?: HTMLAttributes['class'] label?: string required?: boolean description?: string status?: FormStatus help?: string layout?: 'vertical' | 'horizontal' labelWidth?: string }>() const id = useId() provide(FORM_ITEM_INJECTION_KEY, id) const statusBorderClass = computed(() => { switch (props.status) { case 'error': return 'border-destructive focus-within:border-destructive focus-within:ring-destructive/20' case 'warning': return 'border-warning focus-within:border-warning focus-within:ring-warning/20' case 'success': return 'border-success focus-within:border-success focus-within:ring-success/20' default: return '' } }) const isHorizontal = computed(() => props.layout === 'horizontal') </script> <template> <div data-uipkge data-slot="form-item" :class=" cn( 'grid gap-1.5', isHorizontal && 'grid-cols-[var(--label-width,140px)_1fr] items-start gap-x-4 gap-y-0', props.class, ) " :style="labelWidth ? { '--label-width': labelWidth } : undefined" > <div v-if="label || $slots.label" class="flex items-center gap-1"> <FormLabel v-if="label" :for="id">{{ label }}</FormLabel> <slot name="label" /> <span v-if="required" class="text-destructive text-sm">*</span> </div> <div class="space-y-1"> <slot /> <p v-if="description" class="text-muted-foreground text-xs">{{ description }}</p> <p v-if="help" class="text-xs" :class="{ 'text-destructive': status === 'error', 'text-warning': status === 'warning', 'text-success': status === 'success', 'text-muted-foreground': !status, }" > {{ help }} </p> </div> </div> </template> -
app/components/ui/form/FormLabel.vue 0.6 kB
<script lang="ts" setup> import type { LabelProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { Label } from '@/components/ui/label' import { useFormField } from './useFormField' const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>() const { error, formItemId } = useFormField() </script> <template> <Label data-uipkge data-slot="form-label" :data-error="!!error" :class="cn('data-[error=true]:text-destructive', props.class)" :for="formItemId" > <slot /> </Label> </template> -
app/components/ui/form/FormMessage.vue 0.5 kB
<script lang="ts" setup> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { useFormField } from './useFormField' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const { error, formMessageId } = useFormField() </script> <template> <p v-if="error" :id="formMessageId" data-uipkge data-slot="form-message" :class="cn('text-destructive text-sm', props.class)" > {{ error }} </p> </template> -
app/components/ui/form/FormSection.vue 0.9 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ title?: string subtitle?: string description?: string divider?: boolean headingLevel?: 'h2' | 'h3' | 'h4' | 'h5' class?: HTMLAttributes['class'] }>() </script> <template> <div data-uipkge data-slot="form-section" :class="cn('space-y-3', props.class)"> <div v-if="divider || title || subtitle" :class="divider && 'border-t pt-4'"> <div v-if="title || subtitle" class="space-y-1"> <component :is="props.headingLevel ?? 'h4'" v-if="title" class="text-sm font-semibold">{{ title }}</component> <p v-if="subtitle" class="text-muted-foreground text-xs">{{ subtitle }}</p> </div> </div> <p v-if="description" class="text-muted-foreground text-xs">{{ description }}</p> <slot /> </div> </template> -
app/components/ui/form/FormStatus.vue 1.1 kB
<script setup lang="ts"> import type { HTMLAttributes, Component } from 'vue' import { CircleAlert, TriangleAlert, CircleCheck } from 'lucide-vue-next' import { cn } from '@/lib/utils' import type { FormStatus } from './types' const props = defineProps<{ class?: HTMLAttributes['class'] status: FormStatus message: string }>() const statusConfig: Record<FormStatus, { icon: Component; container: string }> = { error: { icon: CircleAlert, container: 'bg-destructive/10 text-destructive border-destructive/20', }, warning: { icon: TriangleAlert, container: 'bg-warning/10 text-warning border-warning/30', }, success: { icon: CircleCheck, container: 'bg-success/10 text-success border-success/30', }, } </script> <template> <div v-if="status" data-uipkge data-slot="form-status" :class=" cn('flex items-center gap-2 rounded-md border px-3 py-2 text-sm', statusConfig[status].container, props.class) " > <component :is="statusConfig[status].icon" class="size-4 shrink-0" /> <span>{{ message }}</span> </div> </template> -
app/components/ui/form/index.ts 0.8 kB
export { default as Form } from './Form.vue' export { default as FormField } from './FormField.vue' export { default as FormControl } from './FormControl.vue' export { default as FormDescription } from './FormDescription.vue' export { default as FormItem } from './FormItem.vue' export { default as FormLabel } from './FormLabel.vue' export { default as FormMessage } from './FormMessage.vue' export { default as FormActions } from './FormActions.vue' export { default as FormSection } from './FormSection.vue' export { default as FormStatus } from './FormStatus.vue' export { useFormField } from './useFormField' export { FORM_ITEM_INJECTION_KEY, FORM_FIELD_INJECTION_KEY } from './injectionKeys' export type { FormFieldContext } from './injectionKeys' export type { FormStatus as FormStatusValue } from './types' export { useForm } from '@tanstack/vue-form' -
app/components/ui/form/injectionKeys.ts 0.6 kB
import type { ComputedRef, InjectionKey } from 'vue' export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string> export interface FormFieldContext { name: string error: ComputedRef<string | undefined> valid: ComputedRef<boolean> isDirty: ComputedRef<boolean> isTouched: ComputedRef<boolean> } export const FORM_FIELD_INJECTION_KEY = Symbol() as InjectionKey<FormFieldContext> export interface TanstackFormApi { Field: unknown handleSubmit: () => void | Promise<void> } export const FORM_INSTANCE_INJECTION_KEY = Symbol() as InjectionKey<TanstackFormApi> -
app/components/ui/form/types.ts 0.1 kB
export type FormStatus = 'error' | 'warning' | 'success' | null | undefined -
app/components/ui/form/useFormField.ts 0.7 kB
import { computed, inject } from 'vue' import { FORM_FIELD_INJECTION_KEY, FORM_ITEM_INJECTION_KEY } from './injectionKeys' export function useFormField() { const fieldContext = inject(FORM_FIELD_INJECTION_KEY) const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY) if (!fieldContext) throw new Error('useFormField should be used within <FormField>') const id = fieldItemContext return { id, name: computed(() => fieldContext.name), formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, valid: fieldContext.valid, isDirty: fieldContext.isDirty, isTouched: fieldContext.isTouched, error: fieldContext.error, } }
Raw manifest: https://uipkge.dev/r/vue/form.json