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

Basic.vuevue
<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.

CustomControl.vuevue
<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.

SlugInput.vuevue
<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>
/posts/

The field writes to article.slug and still gets IDs, errors, and validation from the form.

launch-notes

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.

GroupReturnsWhy it matters
identityid, name, path, htmlName, htmlIdGenerated from props and the nearest DomForm path.
statevalue, errors, interaction, modification, validation, validLocal state-machine axes merged with any parent form state plus derived booleans.
visibilityvisible, disabled, readOnlyField state the control and wrapper both need to respect.
attrsfieldAttrs, inputAttrsReady-to-bind attributes for DomField and the native control.
actionsonInput, onFocus, onBlur, validate, setValue, setFieldStateHandlers 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.

ManualLayout.vuevue
<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.

HeadlessFieldTemplate.vuevue
<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.

0 fields, 0 errors, unknown validation

Name

Value
HTML name
HTML id
Errors
None
interaction: modification: validation:
focused: falsetouched: falsedirty: falsevalidating: falseinvalid: falsevalid: false

Email

Value
HTML name
HTML id
Errors
None
interaction: modification: validation:
focused: falsetouched: falsedirty: falsevalidating: falseinvalid: falsevalid: false
FormFieldStatePanel.vuevue
<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.

Invoice to

Details

ItemQtyRateTotal
£1,200
£1,700
£450
Subtotal£3,350
Total£3,350
InvoiceFieldLayout.vuevue
<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.

FirstLast
  • First name is required.
  • Last name is required.
GroupedNameFields.vuevue
<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

NameTypeTSDefaultDescription
labelstringstring''Small label shown above the control.
descriptionstringstring''Helper text shown below the control.
htmlForstringstring''ID of the control the label should target.
descriptionIdstringstring''
errorIdstringstring''
invalidbooleanbooleanfalseMark the field invalid and expose data-invalid for styling.
errorsarray | object | stringArray<unknown>[]Validation errors to show below the control.
requiredbooleanbooleanfalseShow a required marker next to the label.
visiblebooleanbooleantrueShow or hide the field wrapper.
chrome'field' | falsestring'field'Render the field wrapper, or false to provide state without rendering chrome.
fieldLayoutobject | function | stringRecord<string, unknown>Optional component used to render this field layout.

Auto-generated from Field.props and inline _edit hints.

Slots

NameScopeDescription
#(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.