Element Layout
Every element except VfHidden renders its control inside one shared component, VfLayout, which supplies everything around it — label, help and description, the required marker, errors and messages, icons and addons, and the ARIA wiring that connects them to the control. Because each element contributes only its control — the <input>, the <select>, a group's children — every prop in this chapter behaves the same on VfText, VfSelect, or VfGroup. Placing elements on the grid is a different concern, covered in Form Structure.
Anatomy
Each region of the layout is a named Part. Hover the live element below to explore them:
These Parts are always available:
| Part | What it is |
|---|---|
root | The outermost wrapper — a <div>, or a <fieldset> on a container with a label. |
labelGroup | Holds the label, the required indicator, and the help text. |
label | A <label for> on field elements; a <span> on choice elements (VfCheckbox, VfRadio, VfCheckboxes, VfRadios) and VfContent; a <span> or <h1>–<h6> on containers. |
required | The required indicator. |
help | The help text below the label. |
elementGroup | The control column — wrapper, description, errors, and messages. |
elementWrapper | The immediate wrapper around the control. |
description | The description below the control. |
errors / error | The error list and each entry in it. |
messages / message | The message list and each entry in it. |
These render only when called for:
| Part | Renders when |
|---|---|
iconGroup, iconBefore, iconAfter | An icon or the loading spinner is present. |
addonGroup, addonBefore, addonAfter | Any addon is present. The addon Parts are callable, receiving (addon, index) per addon — see Callable Parts. |
language | The element is translatable and renders its language addon. |
panel | A role="tabpanel" wrapper while the element is a page of a paged container — see Multi-Page Forms. |
tabs | The tab bar of a tabbed container. |
These names are the keys you target with Parts and Classes, and each content Part has a matching slot; an element's full Part tree, its own control Parts included, is on its reference page — VfText's, for example. An element simply places its control into VfLayout's default slot — the pattern is shown in The Standard Layout — and VfHidden is the only element that skips the layout, having no visible UI.
Label
label is a TProp — a plain string, a Locale Map, an Expression, a ref, or a function of the element's Context:
<script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
email: { type: 'text', label: 'Email address' },
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>What tag it renders depends on the element. Field elements get a <label> whose for points at the control, so clicking it focuses the input. Containers turn their root into a <fieldset> named by the label through aria-labelledby. The naming details live in Accessibility.
A label slot alone is enough to render the label Part — no label prop needed.
Headings
On Containers — VfGroup, VfList, VfGrid, VfMatrix — heading: 1–6 renders the label as an <h1>–<h6> tag:
<VfForm>
<VfGroup name="shipping" label="Shipping" :heading="2">
<VfText name="city" label="City" />
</VfGroup>
</VfForm><template>
<VfForm>
<VfGroup name="shipping" label="Shipping" :heading="2">
<VfText name="city" label="City" />
</VfGroup>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
shipping: {
type: 'group',
label: 'Shipping',
heading: 2,
schema: {
city: { type: 'text', label: 'City' },
},
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>Use it when a container opens a section of the form, so the label doubles as the section's heading. Separating the sections visually is covered in Spacing & Dividers.
Hiding the Label
hideLabel: true drops the label group visually. Field elements keep a screen-reader-only <label> carrying the element's genericName, and a labelled container's fieldset carries it as aria-label — how genericName and displayName resolve is covered in Accessibility. With horizontal columns, a label-less element keeps an empty placeholder label column so its control stays aligned with its labelled neighbors — see Horizontal Labels.
Help and Description
help renders directly below the label, inside the label group. description renders below the control. The split follows their jobs — help explains what the field is for, description guides filling it in. Both are TProps:
<VfForm>
<VfText
name="password"
input-type="password"
label="Password"
help="At least 12 characters"
description="Use a mix of letters, numbers, and symbols."
/>
</VfForm><template>
<VfForm>
<VfText
name="password"
input-type="password"
label="Password"
help="At least 12 characters"
description="Use a mix of letters, numbers, and symbols."
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
password: {
type: 'text',
inputType: 'password',
label: 'Password',
help: 'At least 12 characters',
description: 'Use a mix of letters, numbers, and symbols.',
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>As with the label, a help or description slot alone renders its Part. While they render, both are referenced from the control's aria-describedby, so screen readers announce them with the field — see ARIA Attributes.
Required Indicator
The required indicator renders inside the label group when the element has an active required rule and its showRequired setting includes 'label' — so it needs a visible label to appear. It is aria-hidden; the aria-required attribute on the control carries the meaning for assistive tech. Where else it can appear and how to switch it off is showRequired's story in Validation.
The * itself is not markup — it is CSS content from the requiredContent Token, and the requiredIcon Token swaps in an SVG mask instead. Override either like any Token — see Per-Form and Per-Element Tokens.
Errors and Messages
The errors Part renders below the control as a role="alert" region. It shows the element's own errors plus any hoisted from children or addons, capped by maxErrors — one at a time by default. The messages Part renders below the errors and holds informational notes that don't count as errors.
Everything about what shows and when belongs to Validation — where errors render, how many show, hiding, and hoisting, and Adding Messages and Errors from Code.
Icons
VfText, VfTextarea, VfSelect, and VfField accept iconBefore and iconAfter, rendered inside the control area:
<VfForm>
<VfText
name="search"
label="Search"
icon-before="fa-solid fa-magnifying-glass"
/>
</VfForm><template>
<VfForm>
<VfText
name="search"
label="Search"
icon-before="fa-solid fa-magnifying-glass"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
search: {
type: 'text',
label: 'Search',
iconBefore: 'fa-solid fa-magnifying-glass',
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>Both are DProps, so a ref or Context function can switch the icon with state. The string goes through the configured icon resolver — identity by default, so a CSS class works as-is — and $-prefixed names map through the icons map first.
Loading Indicator
VfText, VfTextarea, and VfSelect also take a loading prop that shows a spinner in place of iconAfter. It accepts the full Condition syntax, just like disabled — see Conditions Beyond Visibility. The spinner also appears on its own while one of the element's async rules is running.
Its shape is the iconLoading Token — an SVG url() applied as a CSS mask, so only the shape matters. The color follows the surrounding text, the size comes from the iconWidth / iconHeight Tokens, and Vueform spins it:
<VfForm>
<VfText
name="username"
label="Username"
loading
:tokens="{
iconLoading: `url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3E%3Cpath d=%22M8 1.5A6.5 6.5 0 1 0 14.5 8%22 fill=%22none%22 stroke=%22currentColor%22 stroke-width=%222%22/%3E%3C/svg%3E')`,
}"
/>
</VfForm><template>
<VfForm>
<VfText
name="username"
label="Username"
loading
:tokens="{
iconLoading: `url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3E%3Cpath d=%22M8 1.5A6.5 6.5 0 1 0 14.5 8%22 fill=%22none%22 stroke=%22currentColor%22 stroke-width=%222%22/%3E%3C/svg%3E')`,
}"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
username: {
type: 'text',
label: 'Username',
loading: true,
tokens: {
iconLoading: `url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3E%3Cpath d=%22M8 1.5A6.5 6.5 0 1 0 14.5 8%22 fill=%22none%22 stroke=%22currentColor%22 stroke-width=%222%22/%3E%3C/svg%3E')`,
},
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>Addons
The same four elements take addonBefore and addonAfter — content beside the control for prefixes, units, or inline actions. An addon can be a string, a Locale Map, an element schema, an array of any of those, or a function, and string addons also resolve {...} Expressions:
<VfForm>
<VfText
name="price"
label="Price"
addon-before="$"
:addon-after="['.00', 'USD']"
/>
</VfForm><template>
<VfForm>
<VfText
name="price"
label="Price"
addon-before="$"
:addon-after="['.00', 'USD']"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
price: {
type: 'text',
label: 'Price',
addonBefore: '$',
addonAfter: ['.00', 'USD'],
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>Element Addons
An addon given as an element schema renders a real element. The supported types are text, select, checkbox, radio, and button:
<VfForm>
<VfText
name="amount"
label="Amount"
:addon-after="{
type: 'select',
name: 'currency',
default: 'USD',
items: ['USD', 'EUR', 'GBP'],
rules: 'required',
}"
/>
</VfForm><template>
<VfForm>
<VfText
name="amount"
label="Amount"
:addon-after="{
type: 'select',
name: 'currency',
default: 'USD',
items: ['USD', 'EUR', 'GBP'],
rules: 'required',
}"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
amount: {
type: 'text',
label: 'Amount',
addonAfter: {
type: 'select',
name: 'currency',
default: 'USD',
items: ['USD', 'EUR', 'GBP'],
rules: 'required',
},
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>The addon is a full member of the form:
- Its value lives in the Model under its own
name, next to the host element's —{ amount: …, currency: … }above. - It renders without its own layout and with
hoiston, so its errors show in the host's error block and are reported under the host's path in the form's errors — see Hoisting Errors. - For
radioaddons, a sharedradioNameputs them in one browser radio group, each still storing its ownradioValueunder its ownname.
Functional Addons
A function receives the element's Context and re-runs reactively. It can return a string, a Locale Map, or an array of addon items:
<VfForm>
<VfTextarea
name="bio"
label="Bio"
rules="max:160"
:addon-after="(el$) => `${160 - (el$.value?.length ?? 0)}`"
/>
</VfForm><template>
<VfForm>
<VfTextarea
name="bio"
label="Bio"
rules="max:160"
:addon-after="(el$) => `${160 - (el$.value?.length ?? 0)}`"
/>
</VfForm>
</template><script setup lang="ts">
import { defineSchema, useForm } from '@vueform/ui'
const schema = defineSchema({
bio: {
type: 'textarea',
label: 'Bio',
rules: 'max:160',
addonAfter: (el$) => `${160 - (el$.value?.length ?? 0)}`,
},
})
const [Form] = useForm({ schema })
</script>
<template>
<Form />
</template>Addon Slots
The addon-before and addon-after slots replace that whole side of the control, and the matching prop is ignored while the slot is present. Wrap the content in VfAddon to keep the addon styling:
Elements placed in the slot are not hoisted automatically — prefer an element addon, or set hoist on them yourself.
The Language Addon
A translatable element (translate: true) automatically renders its per-element language switcher after its after-addons, and the language slot replaces it. Its configuration and behavior are covered in Multilingual Inputs.
Slots
Every content Part has a matching slot — label, required, help, description, errors, messages, icon-before, icon-after, addon-before, addon-after, and language, plus tabs and tab on tabbed containers. Each receives what it would have rendered:
| Slot | Scope |
|---|---|
label | label, plus for, id, tag, and bindings |
required | — |
help | help, id, bindings |
description | description, id, bindings |
errors | errors, bindings |
messages | messages |
icon-before / icon-after | iconBefore / iconAfter |
addon-before / addon-after | — |
language | language, languages |
A slot replaces its entire Part, including the attributes the ARIA wiring depends on — spread bindings (and keep for and id on the label) so the connection to the control survives:
Passing slots from a schema and typing them with defineSlot is covered in Slots, and each element's full slot list with exact scopes is on its reference page under the Slots tab.
Removing the Layout
layout: false renders the control alone — no label, help, description, required marker, errors, or messages.
What belongs to the control stays — icons, the loading spinner, addons, and the language addon still render. The element still registers, validates, and keeps aria-invalid, aria-required, and aria-busy on its control; aria-describedby is dropped, since nothing remains to describe it. There is no error block either, so pair it with form-level errors — see Where Errors Render.
There is no wrapper element at all by default. Setting a root Part or per-element tokens brings back an invisible display: contents wrapper to carry them — see The Root Part. Reach for layout: false when embedding an element in markup you control — a table cell, a toolbar, a composite widget.
Replacing the Layout
The layout prop accepts four values — true (the default, the standard VfLayout), false (above), a component definition, or a registered component name. A name string goes through $security.allowComponents like any other name string, and a rejected one falls back to the standard layout — see $security.
The replacement renders in place of VfLayout and receives a single context prop typed ContextOf<'VfLayout'>. The element itself is context.el$ — its label, help, errors, ids, and handlers all live there. There is no default slot, so the replacement renders the control too, not just the frame around it. To swap the layout of every element at once, set it as a '*element' default — see Wildcard Targets.
A working layout component re-renders the control and its whole wiring, so the honest starting point is a copy of the original VfLayout.vue trimmed to your needs. General template-component mechanics — reading context, typing with ContextOf — are covered in Templates.
Beyond the Layout
The rest of the layout's appearance is owned by other chapters. Column widths and Horizontal Labels, along with Spacing & Dividers, live in form structure. Attributes on any Part go through Parts, the class keys and the app-wide layoutClasses through Classes, and visual values through Per-Form and Per-Element Tokens.