Combine labels, controls, and help text to compose accessible form fields and grouped inputs.

Input Fields

Choose a unique username for your account.

We'll never share your email with anyone.

This field must be filled out.

This field is currently disabled.

This field contains validation errors.

This field is currently disabled.

Textarea Fields

Maximum 500 characters allowed.

Tell us about yourself in a few sentences.

Enter your message so it is long enough to test the layout.

This field contains validation errors.

This field is currently disabled.

Select Fields

Select the country where you currently reside.

Choose your local timezone for accurate scheduling.

This field contains validation errors.

This field is currently disabled.

Checkbox Fields

Receive weekly updates about new features and promotions.

Preferences

Select all that apply to customize your experience.

Radio Fields
Subscription Plan
Battery Level

Choose your preferred battery level.

Enable Touch ID to quickly unlock your device.

Invalid Radio Group
Disabled Radio Group
Switch Fields

Turn on airplane mode to disable all connections.

Receive emails about new products, features, and more.

Manage your privacy preferences.

This switch has validation errors.

This switch is currently disabled.

Slider Fields

Current brightness: 75%

Higher quality uses more bandwidth.

Range: 0.3 - 0.7

$25 - $75

Red: 10%, Green: 20%, Blue: 70%

This slider has validation errors.

This slider is currently disabled.

Native Select Fields

Select the country where you currently reside.

Choose your local timezone for accurate scheduling.

Native select with grouped options using optgroup.

This field contains validation errors.

This field is currently disabled.

OTP Input Fields

Enter the 6-digit code sent to your email.

Enter the code from your authenticator app.

Enter your 4-digit PIN (numbers only).

This OTP field contains validation errors.

This OTP field is currently disabled.

Horizontal Fields

Enter your preferred username.

Write a short description about yourself.

Receive email updates about your account.

Choose your favorite fruit.

Select your country.

Adjust the volume level.

Installation

Install the dependencies

pnpm add cn class-variance-authority github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/field.

It imports the label and separator components, so copy their folders too.

src/lib/components/ui/field/Field.svelte
<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>
src/lib/components/ui/field/FieldContent.svelte
<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>
src/lib/components/ui/field/FieldDescription.svelte
<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)} />
src/lib/components/ui/field/FieldError.svelte
<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}
src/lib/components/ui/field/FieldGroup.svelte
<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>
src/lib/components/ui/field/FieldItem.svelte
<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>
src/lib/components/ui/field/FieldLabel.svelte
<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>
src/lib/components/ui/field/FieldLegend.svelte
<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>
src/lib/components/ui/field/FieldSeparator.svelte
<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>
src/lib/components/ui/field/FieldSet.svelte
<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)} />
src/lib/components/ui/field/FieldTitle.svelte
<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>
src/lib/components/ui/field/types.ts
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;
src/lib/components/ui/field/variants.ts
// 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>;
src/lib/components/ui/field/index.ts
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>
  • FieldSet and FieldLegend render a <fieldset> and its <legend>, which group related fields under one label. FieldLegend takes variant="label" for label-sized text.
  • FieldGroup stacks fields, and FieldSeparator divides them.
  • FieldContent groups a label and its description next to a control in a horizontal field.
  • FieldTitle is 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-invalid on Field to switch the whole field into its error style.
  • Set aria-invalid on the control so assistive technology announces it.
  • Render FieldError right after the control, or inside FieldContent.
<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 Field and Fieldset parts underneath. FieldLabel takes the HTML for attribute in place of React's htmlFor.
  • FieldItem wraps 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.