Combine labels, controls, and help text to compose accessible form fields and grouped inputs.
Installation
Install the dependencies
pnpm add cn class-variance-authority github:sveltery/baseCopy the component
Copy the following files into src/lib/components/ui/field.
It imports the label and separator components, so copy their folders too.
<script lang="ts" generics="F extends FieldHandle = FieldHandle">
import { Field as Primitive } from '@sveltery/base/field';
import type { FieldHandle } from '@sveltery/base/form';
import { cn } from 'cn';
import { fieldVariants } from './variants.js';
import type { FieldProps } from './types.js';
let { children, class: classProp, orientation = 'vertical', ...props }: FieldProps<F> = $props();
</script>
<Primitive.Root {children} {...props}>
{#snippet render(root, state, content)}
<!-- shadcn styles `data-[invalid=true]` and `data-[disabled=true]`, so Base's state writes "true". -->
<div {...root} role="group" data-slot="field" data-orientation={orientation} data-invalid={state.valid === false ? 'true' : root['data-invalid']} data-disabled={state.disabled ? 'true' : root['data-disabled']} class={cn(fieldVariants({ orientation }), classProp)}>{@render content?.()}</div>
{/snippet}
</Primitive.Root><script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from 'cn';
let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="field-content" {...props} class={cn('gap-0.5 group/field-content flex flex-1 flex-col leading-snug', classProp)}>
{@render children?.()}
</div><script lang="ts">
import type { ComponentProps } from 'svelte';
import { Field as Primitive } from '@sveltery/base/field';
import { cn } from 'cn';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Description> = $props();
</script>
<Primitive.Description data-slot="field-description" {children} {...props} class={cn('text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance', 'last:mt-0 nth-last-2:-mt-1', '[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', classProp)} /><script lang="ts">
import type { ComponentProps } from 'svelte';
import { Field as Primitive } from '@sveltery/base/field';
import { cn } from 'cn';
let { children, class: classProp, errors, match, ...props }: ComponentProps<typeof Primitive.Error> & { errors?: Array<{ message?: string } | undefined> } = $props();
const messages = $derived([...new Set(errors?.map((error) => error?.message).filter((message) => !!message))]);
// shadcn's children and `errors` show what they are given. A bare FieldError shows the field's own error.
const explicit = $derived(match === undefined && (children !== undefined || errors !== undefined));
const className = $derived(cn('text-destructive text-sm font-normal [&>ul]:ml-4 [&>ul]:flex [&>ul]:list-disc [&>ul]:flex-col [&>ul]:gap-1', classProp));
</script>
{#snippet list()}
{#if messages.length === 1}
{messages[0]}
{:else}
<ul class="ml-4 flex list-disc flex-col gap-1">
{#each messages as message (message)}<li>{message}</li>{/each}
</ul>
{/if}
{/snippet}
{#if !explicit}
<Primitive.Error role="alert" data-slot="field-error" {match} {children} {...props} class={className} />
{:else if children || messages.length}
<Primitive.Error role="alert" data-slot="field-error" match children={children ?? list} {...props} class={className} />
{/if}<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from 'cn';
let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="field-group" {...props} class={cn('gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col', classProp)}>
{@render children?.()}
</div><script lang="ts">
import { Field as Primitive, type FieldItemProps } from '@sveltery/base/field';
import { cn } from 'cn';
import { fieldVariants, type FieldVariants } from './variants.js';
let { children, class: classProp, orientation = 'horizontal', ...props }: FieldItemProps & FieldVariants = $props();
</script>
<Primitive.Item {children} {...props}>
{#snippet render(item, state, content)}
<!-- shadcn styles `data-[invalid=true]` and `data-[disabled=true]`, so Base's state writes "true". -->
<div {...item} role="group" data-slot="field" data-orientation={orientation} data-invalid={state.valid === false ? 'true' : item['data-invalid']} data-disabled={state.disabled ? 'true' : item['data-disabled']} class={cn(fieldVariants({ orientation }), classProp)}>{@render content?.()}</div>
{/snippet}
</Primitive.Item><script lang="ts">
import type { ComponentProps } from 'svelte';
import type { HTMLLabelAttributes } from 'svelte/elements';
import { Field as Primitive } from '@sveltery/base/field';
import { cn } from 'cn';
import { Label } from '../label/index.js';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Label> & Pick<HTMLLabelAttributes, 'for'> = $props();
</script>
<Primitive.Label {children} {...props}>
{#snippet render(label, _state, content)}
<Label {...label} data-slot="field-label" class={cn('has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit', 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col', classProp)}>{@render content?.()}</Label>
{/snippet}
</Primitive.Label><script lang="ts">
import { Fieldset as Primitive, type FieldsetLegendProps } from '@sveltery/base/fieldset';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from 'cn';
let { children, class: classProp, variant = 'legend', ...props }: FieldsetLegendProps & { variant?: 'legend' | 'label' } = $props();
</script>
<Primitive.Legend {children} {...props}>
{#snippet render(legend, _state, content)}
<legend {...legend as HTMLAttributes<HTMLLegendElement>} data-slot="field-legend" data-variant={variant} class={cn('mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base', classProp)}>{@render content?.()}</legend>
{/snippet}
</Primitive.Legend><script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from 'cn';
import { Separator } from '../separator/index.js';
let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="field-separator" data-content={!!children} {...props} class={cn('-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative', classProp)}>
<Separator class="absolute inset-0 top-1/2" />
{#if children}
<span class="text-muted-foreground px-2 relative mx-auto block w-fit bg-background" data-slot="field-separator-content">{@render children()}</span>
{/if}
</div><script lang="ts">
import { Fieldset as Primitive, type FieldsetRootProps } from '@sveltery/base/fieldset';
import { cn } from 'cn';
let { children, class: classProp, ...props }: FieldsetRootProps = $props();
</script>
<Primitive.Root data-slot="field-set" {children} {...props} class={cn('gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col', classProp)} /><script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from 'cn';
let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="field-label" {...props} class={cn('gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center', classProp)}>
{@render children?.()}
</div>import type { FieldRootProps } from '@sveltery/base/field';
import type { FieldHandle } from '@sveltery/base/form';
import type { FieldVariants } from './variants.js';
export type FieldProps<F extends FieldHandle = FieldHandle> = FieldRootProps<F> & FieldVariants;// Adapted from shadcn-ui/ui d75a96ab; MIT: packages/ui/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
export const fieldVariants = cva('data-[invalid=true]:text-destructive gap-2 group/field flex w-full', {
variants: {
orientation: {
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal: 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive: 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: { orientation: 'vertical' },
});
export type FieldVariants = VariantProps<typeof fieldVariants>;export { default as Field } from './Field.svelte';
export { default as FieldItem } from './FieldItem.svelte';
export { default as FieldLabel } from './FieldLabel.svelte';
export { default as FieldDescription } from './FieldDescription.svelte';
export { default as FieldError } from './FieldError.svelte';
export { default as FieldGroup } from './FieldGroup.svelte';
export { default as FieldLegend } from './FieldLegend.svelte';
export { default as FieldSeparator } from './FieldSeparator.svelte';
export { default as FieldSet } from './FieldSet.svelte';
export { default as FieldContent } from './FieldContent.svelte';
export { default as FieldTitle } from './FieldTitle.svelte';
export { fieldVariants } from './variants.js';
export type { FieldVariants } from './variants.js';
export type { FieldProps } from './types.js';Update the import paths
Change the imports to match your project setup.
Usage
<script lang="ts">
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
</script><FieldSet>
<FieldLegend>Profile</FieldLegend>
<FieldDescription>This appears on invoices and emails.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel for="name">Full name</FieldLabel>
<Input id="name" autocomplete="off" placeholder="Evil Rabbit" />
<FieldDescription>This appears on invoices and emails.</FieldDescription>
</Field>
<Field>
<FieldLabel for="username">Username</FieldLabel>
<Input id="username" autocomplete="off" aria-invalid />
<FieldError>Choose another username.</FieldError>
</Field>
<Field orientation="horizontal">
<Switch id="newsletter" />
<FieldLabel for="newsletter">Subscribe to the newsletter</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>Anatomy
Field is a <div role="group">, FieldLabel is a <label>, and FieldDescription is a <p>. Each part renders through its Sveltery Base Field part, so inside a Field the label, the description and the error link to an Input, Textarea or Native Select on their own. You can still pair a label with any control by hand: give the control an id and the label a matching for.
<Field>
<FieldLabel for="input-id">Label</FieldLabel>
<!-- Input, Select, Switch, etc. -->
<FieldDescription>Optional helper text.</FieldDescription>
<FieldError>Validation message.</FieldError>
</Field>FieldSetandFieldLegendrender a<fieldset>and its<legend>, which group related fields under one label.FieldLegendtakesvariant="label"for label-sized text.FieldGroupstacks fields, andFieldSeparatordivides them.FieldContentgroups a label and its description next to a control in a horizontal field.FieldTitleis a heading for a field that has no single control to label.
orientation on Field is vertical (the default), horizontal or responsive. A responsive field is vertical until its FieldGroup is wide enough.
Validation and errors
Inside a Form, Field validates its control and shows the error in a bare FieldError. Without one, you decide when a field is invalid and mark it:
- Set
data-invalidonFieldto switch the whole field into its error style. - Set
aria-invalidon the control so assistive technology announces it. - Render
FieldErrorright after the control, or insideFieldContent.
<Field data-invalid>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" type="email" aria-invalid />
<FieldError>Enter a valid email address.</FieldError>
</Field>FieldError is a <div role="alert">. It renders its children, or the messages in errors, an array of { message?: string } objects. Several messages render as a list. With neither, it shows the field's own error, and renders nothing while the field is valid.
<FieldError errors={[{ message: 'Enter a valid email address.' }]} />A disabled field works the same way. Set data-disabled on Field and disabled on the control.
Choice controls
For a Checkbox, Switch or Radio Group option, use a horizontal Field and pair the label with the control's id:
<FieldSet>
<FieldLegend variant="label">Plan</FieldLegend>
<RadioGroup defaultValue="starter">
<Field orientation="horizontal">
<RadioGroupItem value="starter" id="plan-starter" />
<FieldLabel for="plan-starter">Starter</FieldLabel>
</Field>
<Field orientation="horizontal">
<RadioGroupItem value="pro" id="plan-pro" />
<FieldLabel for="plan-pro">Pro</FieldLabel>
</Field>
</RadioGroup>
</FieldSet>To make the whole option a selectable card, wrap the Field in a FieldLabel:
<FieldLabel for="plan-pro">
<Field orientation="horizontal">
<RadioGroupItem value="pro" id="plan-pro" />
<FieldContent>
<FieldTitle>Pro</FieldTitle>
<FieldDescription>For growing businesses.</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>Forms
name on Field is what a form submits, and field binds a SvelteKit remote form field. See the Forms guide for building forms with Field and Form.
Differences from shadcn/ui
- The parts match shadcn/ui's Base UI variant, and compose Sveltery Base's
FieldandFieldsetparts underneath.FieldLabeltakes the HTMLforattribute in place of React'shtmlFor. FieldItemwraps one option of a radio or checkbox group, so each option has its own label while the group stays one field.
API reference
The Field parts take their Sveltery Base part's props and their element's attributes. Field takes Base Field.Root's name, field, type, validate, invalid and disabled, and adds orientation. FieldLegend adds variant, and FieldError adds errors. The controls you put inside a field come from their own components, such as Input and Select.