Component
Field
<DomField>Shared form chrome for labels, required markers, descriptions, errors, and custom controls.
Purpose
The shared form wrapper
Use it for custom controls
When an input is not already covered by an DOM Studio form component, wrap the control with DomField to keep labels and descriptions consistent.
Keep accessible labels
Pass htmlFor when the slotted control has an id, so clicking the label focuses the underlying input.
Avoid duplicated chrome
Packaged inputs share fieldProps and useField, then render DomField by default. Set :chrome="false" when a parent DomField owns the visual layout.
Validation
Invalid fields
Pass invalid or data-invalid to mark the field wrapper invalid. Inputs using the shared dom-input or dom-textarea utility automatically pick up the destructive border and focus ring.
<DomField label="Email" html-for="email" data-invalid>
<input id="email" name="email" class="dom-input" aria-invalid="true" />
</DomField>Demo
Native control
A plain input receives the same label, required marker, spacing, and helper copy as the packaged form controls.
Used for account notifications and sign-in recovery.
Value: empty
<script setup>
import { ref } from 'vue';
import { DomField } from '@getdom/studio/vue';
const email = ref('');
</script>
<template>
<div class="w-full max-w-sm">
<DomField
label="Email address"
description="Used for account notifications and sign-in recovery."
html-for="field-email"
required
>
<input
id="field-email"
v-model="email"
type="email"
placeholder="steve@example.com"
class="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm text-fg outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
</DomField>
<p class="mt-3 text-xs text-muted-fg">Value: <code class="text-fg">{{ email || 'empty' }}</code></p>
</div>
</template>
Demo
Custom control
Use DomField around composed controls such as button groups, pickers, uploaders, or custom server-backed widgets.
Digest cadence *
DomField can wrap custom controls when you need field chrome without writing a new input component.
<script setup>
import { ref } from 'vue';
import { DomButton, DomField } from '@getdom/studio/vue';
const cadence = ref('weekly');
const options = [
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
];
</script>
<template>
<div class="w-full max-w-md">
<DomField
label="Digest cadence"
description="DomField can wrap custom controls when you need field chrome without writing a new input component."
required
>
<div class="flex flex-wrap gap-2">
<DomButton
v-for="option in options"
:key="option.value"
type="button"
size="sm"
:variant="cadence === option.value ? 'primary' : 'secondary'"
:aria-pressed="cadence === option.value"
@click="cadence = option.value"
>
{{ option.label }}
</DomButton>
</div>
</DomField>
</div>
</template>
Component pattern
Create your own form input
A custom input should compose fieldProps, call useField, and render DomField. That gives it standalone v-model, parent form registration, generated names and IDs, validation state, and optional :chrome="false" support.
A slug input with custom presentation, while still using the same form state contract as packaged controls. The rendered demo wraps it in DomForm.
<script setup>
import { DomField, fieldProps, useField } from '@getdom/studio/vue';
const props = defineProps({
...fieldProps,
modelValue: {
type: String,
default: '',
},
prefix: {
type: String,
default: '/posts/',
},
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
const field = useField(props, emit, { idPrefix: 'slug-input' });
function update(value) {
field.onInput(
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
);
}
</script>
<template>
<DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
<div
class="flex overflow-hidden rounded-xl border border-input bg-background text-sm shadow-sm transition focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30 data-[invalid]:border-destructive"
:data-invalid="field.invalid.value ? '' : undefined"
>
<span class="flex items-center border-r border-border bg-secondary px-3 font-mono text-muted-fg">{{ prefix }}</span>
<input
v-bind="field.inputAttrs.value"
class="min-w-0 flex-1 bg-transparent px-3 py-2 text-fg outline-none placeholder:text-muted-fg"
@input="update($event.target.value)"
@focus="field.onFocus"
@blur="field.onBlur"
/>
</div>
</DomField>
</template>
Behaviour
What useField gives you
useField() is the behaviour layer. It reads and writes the nearest DomForm when one exists, falls back to standalone v-model when it does not, and returns the state and attributes needed by both DomField and the real control. DomField itself stays presentational: it receives fieldAttrs or listens to child fields through a display-only provider.
| Group | Returns | Why it matters |
|---|---|---|
| identity | id, name, path, htmlName, htmlId | Generated from props and the nearest DomForm path. |
| state | value, errors, interaction, modification, validation, valid | Local state-machine axes merged with any parent form state plus derived booleans. |
| visibility | visible, disabled, readOnly | Field state the control and wrapper both need to respect. |
| attrs | fieldAttrs, inputAttrs | Ready-to-bind attributes for DomField and the native control. |
| actions | onInput, onFocus, onBlur, validate, setValue, setFieldState | Handlers that keep v-model, DomForm, and validation state aligned. |
Bind field.fieldAttrs.value to DomField. This is the display state: label, helper text, IDs, required marker, errors, and visibility.
{
label: 'Article slug',
description: 'The field writes to article.slug.',
htmlFor: 'article_slug',
descriptionId: 'article_slug-description',
errorId: '',
invalid: false,
required: true,
errors: [],
visible: true,
} Bind field.inputAttrs.value to the native input. This automates form names, IDs, ARIA, invalid styling, and disabled/read-only wiring.
{
id: 'article_slug',
name: 'article[slug]',
value: 'launch-notes',
placeholder: 'launch-notes',
disabled: undefined,
readonly: undefined,
required: true,
'aria-invalid': undefined,
'aria-describedby': 'article_slug-description',
'aria-errormessage': undefined,
'data-invalid': undefined,
}Headless
Wrap a control with custom chrome
DomField can act as a headless display provider for child controls. Wrap the control in DomField, set the inner packaged input to :chrome="false", and use DomField slots to place labels, controls, descriptions, and errors.
The display provider is separate from the DomForm data provider, so nested visual fields do not create form scopes, change field paths, or write values. If all you need is your own markup, make the wrapping DomField headless with :chrome="false" and read the slot props yourself.
The wrapper DomField renders no chrome, but its slot provides the label target, invalid state, description, and aggregated child errors for custom markup.
Used for receipts.
<script setup>
import { ref } from 'vue';
import { DomEmailInput, DomField } from '@getdom/studio/vue';
const email = ref('steve.example');
</script>
<template>
<div class="w-full max-w-xl">
<DomField
label="Email"
description="Used for receipts."
required
:chrome="false"
v-slot="{ label, description, descriptionId, htmlFor, errors: fieldErrors, errorId, invalid }"
>
<div class="grid gap-2 sm:grid-cols-[8rem_1fr] sm:items-start">
<label
:for="htmlFor"
class="pt-2 text-sm font-semibold"
:class="invalid ? 'text-destructive' : 'text-muted-fg'"
>
{{ label }}
<span aria-hidden="true">*</span>
</label>
<div class="min-w-0 space-y-1">
<DomEmailInput
id="manual-layout-email"
v-model="email"
name="email"
:errors="errors"
:chrome="false"
required
/>
<p
v-if="description"
:id="descriptionId || undefined"
class="text-xs leading-5 text-muted-fg"
>
{{ description }}
</p>
<ul
v-if="fieldErrors.length"
:id="errorId || undefined"
class="space-y-1 text-xs leading-5 text-destructive"
>
<li v-for="error in fieldErrors" :key="error">{{ error }}</li>
</ul>
</div>
</div>
</DomField>
</div>
</template>
The label moves to the left, turns red when invalid, and errors live in a popover while the underlying DomTextInput still owns value, ids, and aria state.
Validation
- Use lowercase letters, numbers, and hyphens only.
- Spaces are not allowed.
Use lowercase letters, numbers, and hyphens only. Spaces are not allowed.
Shown in workspace URLs and invite links.
<script setup>
import { ref } from 'vue';
import { DomField, DomPopover, DomTextInput } from '@getdom/studio/vue';
const workspaceSlug = ref('Acme Studio');
const errors = [
'Use lowercase letters, numbers, and hyphens only.',
'Spaces are not allowed.',
];
</script>
<template>
<div class="w-full max-w-xl">
<DomField
label="Workspace slug"
description="Shown in workspace URLs and invite links."
html-for="workspaceSlug"
:errors="errors"
required
class="grid gap-3 sm:grid-cols-[8rem_1fr] sm:items-start"
>
<template #label="{ label, htmlFor, required, invalid }">
<label
:for="htmlFor"
class="pt-2 text-sm font-semibold transition-colors"
:class="invalid ? 'text-destructive' : 'text-muted-fg'"
>
{{ label }}
<span v-if="required" aria-hidden="true">*</span>
</label>
</template>
<template #default="{ errors: fieldErrors }">
<div class="flex min-w-0 items-start gap-2">
<DomTextInput
id="workspaceSlug"
v-model="workspaceSlug"
name="workspaceSlug"
placeholder="acme-studio"
:errors="errors"
:chrome="false"
required
/>
<DomPopover
v-if="fieldErrors.length"
position="end"
width="w-72"
padding="p-3"
>
<template #trigger>
<button
type="button"
class="mt-1 inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-sm font-semibold text-destructive transition hover:bg-destructive/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/30"
aria-label="Show validation errors"
>
!
</button>
</template>
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-wider text-destructive">Validation</p>
<ul class="space-y-1 text-xs leading-5 text-fg">
<li v-for="error in fieldErrors" :key="error">{{ error }}</li>
</ul>
</div>
</DomPopover>
</div>
</template>
<template #errors="{ errors, errorId }">
<p v-if="errors.length" :id="errorId || undefined" class="sr-only">
{{ errors.join(' ') }}
</p>
</template>
<template #description="{ description, descriptionId }">
<p
v-if="description"
:id="descriptionId || undefined"
class="text-xs leading-5 text-muted-fg sm:col-start-2"
>
{{ description }}
</p>
</template>
</DomField>
</div>
</template>
Form state
Display field state separately
When the surrounding UI should not use DomField slots at all, the DomForm slot exposes the form API. Read field values, field state, generated IDs, native names, paths, and errors from the form, then render them wherever the design needs them.
The inputs render without field chrome, while a separate panel reads each field through form.getFieldState(), form.getValue(), and the form path helpers.
<script setup>
import { nextTick, onMounted, ref } from 'vue';
import { DomButton, DomEmailInput, DomForm, DomTextInput } from '@getdom/studio/vue';
const formRef = ref(null);
const account = ref({
name: '',
email: 'steve.example.com',
});
const inspectedFields = [
{ name: 'name', label: 'Name' },
{ name: 'email', label: 'Email' },
];
const stateAxes = ['interaction', 'modification', 'validation'];
const derivedStateKeys = ['focused', 'touched', 'dirty', 'validating', 'invalid', 'valid'];
onMounted(async () => {
await nextTick();
});
function fieldSnapshot(form, name) {
return form.getState().fields[form.getFieldPath(name)];
}
function errorMessage(error) {
if (typeof error === 'string') return error;
return error?.message || String(error);
}
</script>
<template>
<DomForm ref="formRef" v-model="account" name="account" v-slot="{ form, state }" class="w-full max-w-4xl space-y-5">
<div class="grid gap-4 lg:grid-cols-[1fr_1.35fr]">
<div class="space-y-4">
<div class="grid gap-2 sm:grid-cols-[6rem_1fr] sm:items-start">
<label :for="form.getHtmlId('name')" class="pt-2 text-sm font-semibold text-muted-fg">
Name
</label>
<DomTextInput
name="name"
placeholder="DOM Studio"
:chrome="false"
required
/>
</div>
<div class="grid gap-2 sm:grid-cols-[6rem_1fr] sm:items-start">
<label :for="form.getHtmlId('email')" class="pt-2 text-sm font-semibold text-muted-fg">
Email
</label>
<DomEmailInput
name="email"
:chrome="false"
required
/>
</div>
<div class="flex items-center justify-between rounded-md border border-border bg-secondary/35 px-3 py-2">
<div class="text-xs text-muted-fg">
<span class="font-medium text-fg">{{ state.fieldCount }}</span> fields,
<span class="font-medium text-fg">{{ state.errorCount }}</span> errors,
<span class="font-medium text-fg">{{ state.validation }}</span> validation
</div>
<DomButton type="button" size="sm" @click="form.validate()">
Validate
</DomButton>
</div>
</div>
<div class="space-y-3">
<div
v-for="item in inspectedFields"
:key="item.name"
class="rounded-lg border border-border bg-background p-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-fg">{{ item.label }}</h3>
<p class="mt-1 font-mono text-[11px] text-muted-fg">
{{ fieldSnapshot(form, item.name)?.path }}
</p>
</div>
<span
class="rounded-full px-2 py-0.5 text-[11px] font-medium"
:class="fieldSnapshot(form, item.name)?.state.invalid ? 'bg-destructive/10 text-destructive' : 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'"
>
{{ fieldSnapshot(form, item.name)?.state.validation }}
</span>
</div>
<dl class="mt-3 grid gap-2 text-xs sm:grid-cols-2">
<div>
<dt class="font-medium text-muted-fg">Value</dt>
<dd class="mt-0.5 truncate font-mono text-fg">
{{ JSON.stringify(fieldSnapshot(form, item.name)?.value) }}
</dd>
</div>
<div>
<dt class="font-medium text-muted-fg">HTML name</dt>
<dd class="mt-0.5 truncate font-mono text-fg">
{{ fieldSnapshot(form, item.name)?.htmlName }}
</dd>
</div>
<div>
<dt class="font-medium text-muted-fg">HTML id</dt>
<dd class="mt-0.5 truncate font-mono text-fg">
{{ fieldSnapshot(form, item.name)?.htmlId }}
</dd>
</div>
<div>
<dt class="font-medium text-muted-fg">Errors</dt>
<dd class="mt-0.5 text-destructive">
<span v-if="!fieldSnapshot(form, item.name)?.errors.length">None</span>
<span v-else>{{ fieldSnapshot(form, item.name).errors.map(errorMessage).join(' ') }}</span>
</dd>
</div>
</dl>
<div class="mt-3 flex flex-wrap gap-1.5">
<span
v-for="key in stateAxes"
:key="key"
class="rounded border px-2 py-1 text-[11px] font-medium"
:class="fieldSnapshot(form, item.name)?.state[key] === 'unknown' || fieldSnapshot(form, item.name)?.state[key] === 'clean' || fieldSnapshot(form, item.name)?.state[key] === 'untouched' ? 'border-border bg-secondary/40 text-muted-fg' : 'border-primary/30 bg-primary/10 text-primary'"
>
{{ key }}: {{ fieldSnapshot(form, item.name)?.state[key] }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="key in derivedStateKeys"
:key="key"
class="rounded border px-2 py-1 text-[11px] font-medium"
:class="fieldSnapshot(form, item.name)?.state[key] ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border bg-secondary/40 text-muted-fg'"
>
{{ key }}: {{ fieldSnapshot(form, item.name)?.state[key] ? 'true' : 'false' }}
</span>
</div>
</div>
</div>
</div>
</DomForm>
</template>
Reusable layout
Provide a field layout component
Use scoped slots for one-off exceptions. When a whole section should share a field shape, pass a layout component through fieldLayout on DomField. Use :chrome="false" when the field only provides layout to descendant controls. Nested forms can still scope data paths, but they do not know or care about the layout.
The invoice form uses DomField to provide a custom letter layout, then uses another DomField to provide the shipped DomGridFieldLayout inside line item rows.
<script setup>
import { computed, ref } from 'vue';
import {
DomField,
DomForm,
DomGridFieldLayout,
DomNumberInput,
DomTextInput,
} from '@getdom/studio/vue';
import InvoiceLetterFieldLayout from './InvoiceLetterFieldLayout.vue';
const invoice = ref({
number: 'INV-2026-014',
client: 'Bright Studio',
contact: 'billing@bright.example',
due: '30 June 2026',
lineItems: [
{ description: 'Design system audit', quantity: 1, rate: 1200 },
{ description: 'Form component implementation', quantity: 2, rate: 850 },
{ description: 'Documentation pass', quantity: 1, rate: 450 },
],
});
const total = computed(() => invoice.value.lineItems.reduce((sum, item) => (
sum + (Number(item.quantity) || 0) * (Number(item.rate) || 0)
), 0));
function lineTotal(item) {
return (Number(item.quantity) || 0) * (Number(item.rate) || 0);
}
function money(value) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
}).format(value);
}
</script>
<template>
<DomForm
v-model="invoice"
name="invoice"
class="w-full max-w-4xl space-y-6"
>
<DomField :field-layout="InvoiceLetterFieldLayout" :chrome="false">
<div class="grid gap-6 lg:grid-cols-[1fr_18rem]">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-fg">Invoice to</p>
<DomTextInput name="client" label="Client" />
<DomTextInput name="contact" label="Contact" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-fg">Details</p>
<DomTextInput name="number" label="Number" />
<DomTextInput name="due" label="Due" />
</div>
</div>
</DomField>
<div class="overflow-x-auto">
<div class="min-w-[44rem]">
<div class="grid grid-cols-[minmax(0,1.5fr)_5rem_7rem_7rem] gap-2 border-b border-border pb-2 text-xs font-semibold uppercase tracking-wider text-muted-fg">
<span>Item</span>
<span>Qty</span>
<span>Rate</span>
<span class="text-right">Total</span>
</div>
<DomForm
v-for="(item, index) in invoice.lineItems"
:key="index"
:name="`lineItems.${index}`"
class="grid grid-cols-[minmax(0,1.5fr)_5rem_7rem_7rem] gap-2 border-b border-border/70 py-3"
>
<DomField :field-layout="DomGridFieldLayout" :chrome="false">
<DomTextInput
:id="`line-${index}-description`"
name="description"
label="Item"
/>
<DomNumberInput
:id="`line-${index}-quantity`"
name="quantity"
label="Quantity"
:min="1"
/>
<DomNumberInput
:id="`line-${index}-rate`"
name="rate"
label="Rate"
:min="0"
:step="50"
/>
<DomField
:id="`line-${index}-total-field`"
label="Line total"
:html-for="`line-${index}-total`"
>
<output
:id="`line-${index}-total`"
class="flex h-10 items-center justify-end rounded-md border border-input bg-secondary/50 px-3 text-sm font-semibold text-fg"
>
{{ money(lineTotal(item)) }}
</output>
</DomField>
</DomField>
</DomForm>
</div>
</div>
<div class="flex justify-end">
<div class="w-full max-w-xs space-y-2 text-sm">
<div class="flex justify-between text-muted-fg">
<span>Subtotal</span>
<span>{{ money(total) }}</span>
</div>
<div class="flex justify-between border-t border-border pt-3 text-lg font-semibold text-fg">
<span>Total</span>
<span>{{ money(total) }}</span>
</div>
</div>
</div>
</DomForm>
</template>
This local layout renders labels to the left and keeps the normal field error and description slots.
<script setup>
import { useAttrs } from 'vue';
defineOptions({
inheritAttrs: false,
});
defineProps({
label: { type: String, default: '' },
htmlFor: { type: String, default: '' },
description: { type: String, default: '' },
descriptionId: { type: String, default: '' },
errorId: { type: String, default: '' },
invalid: { type: Boolean, default: false },
errors: { type: Array, default: () => [] },
required: { type: Boolean, default: false },
visible: { type: Boolean, default: true },
fields: { type: Array, default: () => [] },
fieldAttrs: { type: Object, default: () => ({}) },
});
const attrs = useAttrs();
</script>
<template>
<div
v-bind="attrs"
class="grid gap-2 border-b border-dashed border-border/80 py-3 sm:grid-cols-[8rem_1fr] sm:items-start"
:data-invalid="invalid ? '' : undefined"
>
<label
v-if="label"
:for="htmlFor || undefined"
class="pt-2 text-xs font-semibold uppercase tracking-wider"
:class="invalid ? 'text-destructive' : 'text-muted-fg'"
>
{{ label }}
<span v-if="required" aria-hidden="true">*</span>
</label>
<div class="min-w-0 space-y-1">
<slot />
<slot name="errors" :errors="errors" :error-id="errorId" :invalid="invalid" />
<slot name="description" :description="description" :description-id="descriptionId" />
</div>
</div>
</template>
This shipped grid layout keeps labels accessible while letting parent grid headers provide the visible column labels.
<script setup>
import { useAttrs } from 'vue';
defineOptions({
inheritAttrs: false,
});
defineProps({
label: { type: String, default: '' },
htmlFor: { type: String, default: '' },
description: { type: String, default: '' },
descriptionId: { type: String, default: '' },
errorId: { type: String, default: '' },
invalid: { type: Boolean, default: false },
errors: { type: Array, default: () => [] },
required: { type: Boolean, default: false },
visible: { type: Boolean, default: true },
fields: { type: Array, default: () => [] },
fieldAttrs: { type: Object, default: () => ({}) },
});
const attrs = useAttrs();
</script>
<template>
<div
v-bind="attrs"
class="min-w-0 space-y-1"
:data-invalid="invalid ? '' : undefined"
>
<label v-if="label" :for="htmlFor || undefined" class="sr-only">
{{ label }}<span v-if="required"> required</span>
</label>
<slot />
<slot name="errors" :errors="errors" :error-id="errorId" :invalid="invalid" />
<slot name="description" :description="description" :description-id="descriptionId" />
</div>
</template>
Composite layout
Represent multiple fields as one row
For grouped inputs, keep one visual DomField around the row and set the internal controls to :chrome="false". Each input can still write its own form value while shared label and group errors stay in one place.
First and last name are two logical fields, but the user sees one labelled row with combined errors below.
<script setup>
import { computed, ref } from 'vue';
import { DomField, DomForm, DomTextInput } from '@getdom/studio/vue';
const person = ref({
first: '',
last: '',
});
const firstErrors = computed(() => (person.value.first ? [] : ['First name is required.']));
const lastErrors = computed(() => (person.value.last ? [] : ['Last name is required.']));
const nameErrors = computed(() => [...firstErrors.value, ...lastErrors.value]);
</script>
<template>
<DomForm v-model="person" name="person" class="w-full max-w-xl">
<DomField
label="Name"
html-for="name-first"
:errors="nameErrors"
required
class="grid gap-3 sm:grid-cols-[5rem_1fr] sm:items-start"
>
<template #label="{ label, htmlFor, required, invalid }">
<label
:for="htmlFor"
class="pt-2 text-sm font-semibold"
:class="invalid ? 'text-destructive' : 'text-muted-fg'"
>
{{ label }}<span v-if="required" aria-hidden="true">*</span>
</label>
</template>
<div class="min-w-0 space-y-1">
<div class="grid gap-2 sm:grid-cols-2">
<DomTextInput
id="name-first"
v-model="person.first"
name="first"
placeholder="First"
:chrome="false"
:invalid="firstErrors.length > 0"
required
/>
<DomTextInput
id="name-last"
v-model="person.last"
name="last"
placeholder="Last"
:chrome="false"
:invalid="lastErrors.length > 0"
required
/>
</div>
<div class="grid gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-fg sm:grid-cols-2">
<span>First</span>
<span>Last</span>
</div>
</div>
<template #errors="{ errors, errorId }">
<ul
v-if="errors.length"
:id="errorId || undefined"
class="space-y-1 text-xs leading-5 text-destructive sm:col-start-2"
>
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
</template>
</DomField>
</DomForm>
</template>
Usage
Vue
This is the same component used in the rendered native-control demo above, so the source and output stay coupled.
<script setup>
import { ref } from 'vue';
import { DomField } from '@getdom/studio/vue';
const email = ref('');
</script>
<template>
<div class="w-full max-w-sm">
<DomField
label="Email address"
description="Used for account notifications and sign-in recovery."
html-for="field-email"
required
>
<input
id="field-email"
v-model="email"
type="email"
placeholder="steve@example.com"
class="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm text-fg outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
</DomField>
<p class="mt-3 text-xs text-muted-fg">Value: <code class="text-fg">{{ email || 'empty' }}</code></p>
</div>
</template>
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
label | string | string | '' | Small label shown above the control. |
description | string | string | '' | Helper text shown below the control. |
htmlFor | string | string | '' | ID of the control the label should target. |
descriptionId | string | string | '' | — |
errorId | string | string | '' | — |
invalid | boolean | boolean | false | Mark the field invalid and expose data-invalid for styling. |
errors | array | object | string | Array<unknown> | [] | Validation errors to show below the control. |
required | boolean | boolean | false | Show a required marker next to the label. |
visible | boolean | boolean | true | Show or hide the field wrapper. |
chrome | 'field' | false | string | 'field' | Render the field wrapper, or false to provide state without rendering chrome. |
fieldLayout | object | function | string | Record<string, unknown> | — | Optional component used to render this field layout. |
Auto-generated from Field.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #(default) | { invalid, errors, htmlFor, errorId, fields, fieldAttrs } | The form control or custom interactive content. |
| #label | { label, required, htmlFor, invalid, errors, fields, fieldAttrs } | Replace the default label while keeping field layout. |
| #errors | { errors, errorId, invalid, fields, fieldAttrs } | Replace the default error list. |
| #description | { description, descriptionId, fields, fieldAttrs } | Replace the default helper text. |