Skip to content
edmi-ui

Field

Composes label, control, description and error into one accessible unit.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Mandate

Rules the keeper follows after launch.

Allow keeper

Rebalance automatically when drift passes the limit.

Mandate

Rules the keeper follows after launch.

Allow keeper

Rebalance automatically when drift passes the limit.

Mandate

Rules the keeper follows after launch.

Allow keeper

Rebalance automatically when drift passes the limit.

Terminal window
npx shadcn@latest add @edmi-ui/field
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/field
Terminal window
yarn dlx shadcn@latest add @edmi-ui/field
Terminal window
bunx shadcn@latest add @edmi-ui/field

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/field
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/field
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/field
Terminal window
bunx shadcn-vue@latest add @edmi-ui/field

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/field.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/field.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/field.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/field.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
<FieldSet className="w-full max-w-sm">
<FieldLegend>Mandate</FieldLegend>
<FieldDescription>
Rules the keeper follows after launch.
</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel htmlFor="field-name">Index name</FieldLabel>
<Input id="field-name" defaultValue="Magnificent Four" />
</Field>
<Field data-invalid="true">
<FieldLabel htmlFor="field-ticker">Ticker</FieldLabel>
<Input id="field-ticker" aria-invalid defaultValue="mag 4" />
<FieldError>Use 2-6 capital letters.</FieldError>
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Allow keeper</FieldTitle>
<FieldDescription>
Rebalance automatically when drift passes the limit.
</FieldDescription>
</FieldContent>
<Switch defaultChecked />
</Field>
</FieldGroup>
</FieldSet>
<script setup lang="ts">
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
</script>
<template>
<FieldSet class="w-full max-w-sm">
<FieldLegend>Mandate</FieldLegend>
<FieldDescription>Rules the keeper follows after launch.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel for="field-name">Index name</FieldLabel>
<Input id="field-name" default-value="Magnificent Four" />
</Field>
<Field data-invalid="true">
<FieldLabel for="field-ticker">Ticker</FieldLabel>
<Input id="field-ticker" aria-invalid="true" default-value="mag 4" />
<FieldError>Use 2-6 capital letters.</FieldError>
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Allow keeper</FieldTitle>
<FieldDescription>
Rebalance automatically when drift passes the limit.
</FieldDescription>
</FieldContent>
<Switch default-value="true" />
</Field>
</FieldGroup>
</FieldSet>
</template>
<script lang="ts">
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "$lib/components/ui/field";
import { Input } from "$lib/components/ui/input";
import { Switch } from "$lib/components/ui/switch";
</script>
<FieldSet class="w-full max-w-sm">
<FieldLegend>Mandate</FieldLegend>
<FieldDescription>Rules the keeper follows after launch.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel for="field-name">Index name</FieldLabel>
<Input id="field-name" value="Magnificent Four" />
</Field>
<Field>
<FieldLabel for="field-ticker">Ticker</FieldLabel>
<Input id="field-ticker" />
{#if invalid}
<FieldError>Use 2-6 capital letters.</FieldError>
{/if}
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Allow keeper</FieldTitle>
<FieldDescription>
Rebalance automatically when drift passes the limit.
</FieldDescription>
</FieldContent>
<Switch />
</Field>
</FieldGroup>
</FieldSet>
registry/ui/field.tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { useMemo } from "react";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
import { Label } from "@/registry/edmi/ui/label";
import { Separator } from "@/registry/edmi/ui/separator";
// ✦ choice-card depth (v4); a checked card keeps its ring
const ck =
"has-[>[data-slot=field]]:has-data-[checked]:border-ring has-[>[data-slot=field]]:has-data-[checked]:shadow-[0_0_0_1px_var(--ring)]";
const choiceCardElevation = {
sunken: `has-[>[data-slot=field]]:border-sk-bd has-[>[data-slot=field]]:bg-sk-bg has-[>[data-slot=field]]:shadow-sunken ${ck}`,
flat: "",
raised: `has-[>[data-slot=field]]:border-transparent has-[>[data-slot=field]]:shadow-raised ${ck}`,
floating: `has-[>[data-slot=field]]:border-transparent has-[>[data-slot=field]]:shadow-floating ${ck}`,
};
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium text-foreground data-[variant=label]:text-sm data-[variant=legend]:text-base",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive-text",
{
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",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: stock markup; div keeps layout predictable
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
elevation,
...props
}: React.ComponentProps<typeof Label> & {
/** ✦ choice-card depth (only when the label wraps a Field): sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "control");
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit 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]]:border-border has-[>[data-slot=field]]:bg-card has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:shadow-ring has-data-[checked]:border-ring has-data-[checked]:shadow-[0_0_0_1px_var(--ring)] *:data-[slot=field]:p-3.5",
choiceCardElevation[level],
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium text-foreground group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-[13px] leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-foreground",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-[13px] font-normal text-destructive-text", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
};
registry/ui/field/Field.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { FieldVariants } from '.'
import { cn } from '@/registry/edmi/lib/utils'
import { fieldVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
orientation?: FieldVariants['orientation']
}>()
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation ?? 'vertical'"
:class="cn(
fieldVariants({ orientation }),
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/field/FieldContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="field-content"
:class="cn(
'group/field-content flex flex-1 flex-col gap-0.5 leading-snug',
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/field/FieldDescription.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="field-description"
:class="cn(
'text-left text-[13px] leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-foreground',
props.class,
)"
>
<slot />
</p>
</template>
registry/ui/field/FieldError.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
errors?: Array<string | { message: string | undefined } | undefined>
}>()
const content = computed(() => {
if (!props.errors || props.errors.length === 0)
return null
const uniqueErrors = [
...new Map(
props.errors
.filter(Boolean)
.map((error) => {
const message = typeof error === 'string' ? error : error?.message
return [message, error]
}),
).values(),
]
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
return typeof uniqueErrors[0] === 'string' ? uniqueErrors[0] : uniqueErrors[0].message
}
return uniqueErrors.map(error => typeof error === 'string' ? error : error?.message)
})
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-[13px] font-normal text-destructive-text', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
<li v-for="(error, index) in content" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>
registry/ui/field/FieldGroup.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="field-group"
:class="cn(
'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/field/FieldLabel.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
import { Label } from '@/registry/edmi/ui/label'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
/** ✦ choice-card depth (only when the label wraps a Field): sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
}>(), { elevation: undefined })
const level = useElevation(() => props.elevation, 'control')
// ✦ choice-card depth (v4); a checked card keeps its ring
const ck = 'has-[>[data-slot=field]]:has-data-[state=checked]:border-ring has-[>[data-slot=field]]:has-data-[state=checked]:shadow-[0_0_0_1px_var(--ring)]'
const choiceCardElevation = {
sunken: `has-[>[data-slot=field]]:border-sk-bd has-[>[data-slot=field]]:bg-sk-bg has-[>[data-slot=field]]:shadow-sunken ${ck}`,
flat: '',
raised: `has-[>[data-slot=field]]:border-transparent has-[>[data-slot=field]]:shadow-raised ${ck}`,
floating: `has-[>[data-slot=field]]:border-transparent has-[>[data-slot=field]]:shadow-floating ${ck}`,
}
</script>
<template>
<Label
data-slot="field-label"
:class="cn(
'group/field-label peer/field-label flex w-fit 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]]:border-border has-[>[data-slot=field]]:bg-card has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:shadow-ring has-[>[data-slot=field]]:has-data-[state=checked]:border-ring has-[>[data-slot=field]]:has-data-[state=checked]:shadow-[0_0_0_1px_var(--ring)] *:data-[slot=field]:p-3.5',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
choiceCardElevation[level],
props.class,
)"
>
<slot />
</Label>
</template>
registry/ui/field/FieldLegend.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
variant?: 'legend' | 'label'
}>(), {
variant: 'legend',
})
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="cn(
'mb-1.5 font-medium text-foreground data-[variant=label]:text-sm data-[variant=legend]:text-base',
props.class,
)"
>
<slot />
</legend>
</template>
registry/ui/field/FieldSeparator.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { Separator } from '@/registry/edmi/ui/separator'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>
registry/ui/field/FieldSet.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<fieldset
data-slot="field-set"
:class="cn(
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)"
>
<slot />
</fieldset>
</template>
registry/ui/field/FieldTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="field-label"
:class="cn(
'flex w-fit items-center gap-2 text-sm font-medium text-foreground group-data-[disabled=true]/field:opacity-50',
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/field/index.ts
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive-text",
{
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.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";
src/lib/registry/ui/field/field-content.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-content"
class={cn("group/field-content flex flex-1 flex-col gap-0.5 leading-snug", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/field/field-description.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="field-description"
class={cn(
"text-left text-[13px] leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-foreground",
className
)}
{...restProps}
>
{@render children?.()}
</p>
src/lib/registry/ui/field/field-error.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
errors,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
errors?: { message?: string }[];
} = $props();
const hasContent = $derived.by(() => {
// has slotted error
if (children) return true;
// no errors
if (!errors || errors.length === 0) return false;
// has an error but no message
if (errors.length === 1 && !errors[0]?.message) {
return false;
}
return true;
});
const isMultipleErrors = $derived(errors && errors.length > 1);
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
</script>
{#if hasContent}
<div
bind:this={ref}
role="alert"
data-slot="field-error"
class={cn("text-[13px] font-normal text-destructive-text", className)}
{...restProps}
>
{#if children}
{@render children()}
{:else if singleErrorMessage}
{singleErrorMessage}
{:else if isMultipleErrors}
<ul class="ml-4 flex list-disc flex-col gap-1">
{#each errors ?? [] as error, index (index)}
{#if error?.message}
<li>{error.message}</li>
{/if}
{/each}
</ul>
{/if}
</div>
{/if}
src/lib/registry/ui/field/field-group.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-group"
class={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/field/field-label.svelte
<script lang="ts">
import { Label } from "$lib/registry/ui/label/index.js";
import { cn } from "$lib/utils.js";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
elevation = "auto",
...restProps
}: ComponentProps<typeof Label> & {
/** ✦ choice-card depth (a FieldLabel wrapping a Field): sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "control");
// ✦ choice-card depth (v4); a checked card keeps its ring
const ck =
"has-[>[data-slot=field]]:has-data-[state=checked]:border-ring has-[>[data-slot=field]]:has-data-[state=checked]:shadow-[0_0_0_1px_var(--ring)]";
const choiceCardElevation = {
sunken: `has-[>[data-slot=field]]:border-sk-bd has-[>[data-slot=field]]:bg-sk-bg has-[>[data-slot=field]]:shadow-sunken ${ck}`,
flat: "",
raised: `has-[>[data-slot=field]]:border-transparent has-[>[data-slot=field]]:shadow-raised ${ck}`,
floating: `has-[>[data-slot=field]]:border-transparent has-[>[data-slot=field]]:shadow-floating ${ck}`,
};
</script>
<Label
bind:ref
data-slot="field-label"
class={cn(
"group/field-label peer/field-label flex w-fit 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]]:border-border has-[>[data-slot=field]]:bg-card has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:shadow-ring has-[>[data-slot=field]]:has-data-[state=checked]:border-ring has-[>[data-slot=field]]:has-data-[state=checked]:shadow-[0_0_0_1px_var(--ring)] *:data-[slot=field]:p-3.5",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
choiceCardElevation[level.current],
className
)}
{...restProps}
>
{@render children?.()}
</Label>
src/lib/registry/ui/field/field-legend.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
variant = "legend",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
variant?: "legend" | "label";
} = $props();
</script>
<legend
bind:this={ref}
data-slot="field-legend"
data-variant={variant}
class={cn("mb-1.5 font-medium text-foreground data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...restProps}
>
{@render children?.()}
</legend>
src/lib/registry/ui/field/field-separator.svelte
<script lang="ts">
import { Separator } from "$lib/registry/ui/separator/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
} = $props();
const hasContent = $derived(!!children);
</script>
<div
bind:this={ref}
data-slot="field-separator"
data-content={hasContent}
class={cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className)}
{...restProps}
>
<Separator class="absolute inset-0 top-1/2" />
{#if children}
<span
class="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{@render children()}
</span>
{/if}
</div>
src/lib/registry/ui/field/field-set.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLFieldsetAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLFieldsetAttributes> = $props();
</script>
<fieldset
bind:this={ref}
data-slot="field-set"
class={cn("flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className)}
{...restProps}
>
{@render children?.()}
</fieldset>
src/lib/registry/ui/field/field-title.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="field-label"
class={cn("flex w-fit items-center gap-2 text-sm font-medium text-foreground group-data-[disabled=true]/field:opacity-50", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/field/field.svelte
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const fieldVariants = tv({
base: "group/field flex w-full gap-2 data-[invalid=true]:text-destructive-text",
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 FieldOrientation = VariantProps<typeof fieldVariants>["orientation"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: FieldOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="field"
data-orientation={orientation}
class={cn(fieldVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/field/index.ts
import Field from "./field.svelte";
import Content from "./field-content.svelte";
import Description from "./field-description.svelte";
import FieldErr from "./field-error.svelte";
import Group from "./field-group.svelte";
import Label from "./field-label.svelte";
import Legend from "./field-legend.svelte";
import Separator from "./field-separator.svelte";
import FieldSetPart from "./field-set.svelte";
import Title from "./field-title.svelte";
export {
Content,
Content as FieldContent,
Description,
Description as FieldDescription,
Field,
FieldErr as Error,
FieldErr as FieldError,
FieldSetPart as Set,
//
FieldSetPart as FieldSet,
Group,
Group as FieldGroup,
Label,
Label as FieldLabel,
Legend,
Legend as FieldLegend,
Separator,
Separator as FieldSeparator,
Title,
Title as FieldTitle,
};

See the Elevation guide for the levels, roles and layered mode.

A FieldLabel that wraps a Field is a choice card. elevation on the FieldLabel sets its depth (sunken -1, flat, raised +1, floating +2); a checked card keeps the ring border.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Prop Type Default
Field orientation vertical | horizontal | responsive vertical
FieldLegend variant legend | label legend
FieldError errors Array<{ message?: string }> none
FieldLabel elevation ✦ "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • Choice-card labels (a FieldLabel wrapping a Field) highlight with the ring border when checked; elevation adds depth.