Field
Label, control, description and error laid out as one field, alone or in a set.
shadcn's base-nova field with Vibra's choice card and logical sides. A checked choice card — a FieldLabel wrapping a Field — is filled with --brand-muted and drops shadcn's accent border, because in Vibra an outline around a box is what focus and an error look like; focused, the card wears the kit's one 2px outline and the control inside it draws none. A description starts at the inline start and an error list indents from it, so a right-to-left field keeps both under it.
Install
npx shadcn@latest add @vibra/fieldNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function FieldDemo() {
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="workspace-name">Workspace name</FieldLabel>
<Input id="workspace-name" defaultValue="Northwind Analytics" aria-describedby="workspace-name-hint" />
<FieldDescription id="workspace-name-hint">Shown on invoices and in the sidebar.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="workspace-url">Workspace URL</FieldLabel>
<Input id="workspace-url" defaultValue="northwind" aria-describedby="workspace-url-hint" />
<FieldDescription id="workspace-url-hint">app.northwind.example/northwind — lowercase letters and dashes.</FieldDescription>
</Field>
</FieldGroup>
)
}Required and optional
Both said in words inside the label, so they are part of each field's name — never a red star to decode.
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
function Marker({ children }: { children: React.ReactNode }) {
return <span className="font-normal text-muted-foreground">({children})</span>
}
// Required and optional are said in words inside the label, so they are part
// of the field's name — never a red star a reader has to decode, or miss.
export default function FieldRequired() {
return (
<FieldGroup className="w-full max-w-sm gap-4">
<Field>
<FieldLabel htmlFor="field-required-legal">
Legal name <Marker>required</Marker>
</FieldLabel>
<Input id="field-required-legal" required autoComplete="organization" defaultValue="Northwind Analytics Ltd" />
</Field>
<Field>
<FieldLabel htmlFor="field-required-trading">
Trading name <Marker>optional</Marker>
</FieldLabel>
<Input id="field-required-trading" autoComplete="off" placeholder="Northwind" />
</Field>
<Field>
<FieldLabel htmlFor="field-required-po">
Purchase order number <Marker>optional</Marker>
</FieldLabel>
<Input id="field-required-po" autoComplete="off" className="font-mono" />
</Field>
</FieldGroup>
)
}Several errors
FieldError's errors list: one problem reads as a line, several as a list, and each clears once the value stops breaking it.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const RULES = [
{ ok: (v: string) => v.length >= 3, message: "Use at least 3 characters." },
{ ok: (v: string) => /^[a-z0-9-]*$/.test(v), message: "Use lowercase letters, numbers and dashes only." },
{ ok: (v: string) => !/^-|-$/.test(v), message: "Start and end with a letter or a number." },
]
// FieldError takes the problems as a list: one reads as a line, several as a
// bulleted list, and the same message twice is shown once. Each clears as
// soon as the value stops breaking it.
export default function FieldErrors() {
const [subdomain, setSubdomain] = React.useState("NW")
const errors = RULES.filter((rule) => !rule.ok(subdomain)).map((rule) => ({ message: rule.message }))
const invalid = errors.length > 0
return (
<Field className="w-full max-w-sm" data-invalid={invalid || undefined}>
<FieldLabel htmlFor="field-errors-subdomain">Status page address</FieldLabel>
<Input
id="field-errors-subdomain"
value={subdomain}
autoComplete="off"
spellCheck={false}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "field-errors-subdomain-hint field-errors-subdomain-error" : "field-errors-subdomain-hint"}
onChange={(event) => setSubdomain(event.target.value)}
className="font-mono"
/>
<FieldDescription id="field-errors-subdomain-hint">status.northwind.example/{subdomain || "…"}</FieldDescription>
<FieldError id="field-errors-subdomain-error" errors={errors} />
</Field>
)
}Disabled, with a reason
data-disabled on the Field dims its label with the control; the reason stays at full strength and is joined to the field.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// data-disabled on the Field dims its label with the control; the reason is a
// description, which stays at full strength and is joined to the field.
export default function FieldDisabled() {
return (
<Field data-disabled className="w-full max-w-sm">
<FieldLabel htmlFor="field-disabled-domain">Custom domain</FieldLabel>
<Input id="field-disabled-domain" disabled placeholder="status.blueharbor.example" aria-describedby="field-disabled-domain-reason" />
<FieldDescription id="field-disabled-domain-reason">
Custom domains come with the Scale plan. <a href="/settings/billing">Compare plans</a>
</FieldDescription>
</Field>
)
}Label beside the field
orientation="responsive": stacked on a phone, side by side once the group is md wide — the settings row from one markup.
import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// orientation="responsive" stacks the label over the field until the group is
// md wide, then sets them side by side — the settings-page row, from one markup.
// Side by side, a field is as wide as its content unless it says otherwise, so
// both claim one width (the ! outranks the row's own w-auto).
export default function FieldResponsive() {
return (
<FieldGroup className="w-full max-w-2xl">
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="field-responsive-name">Company name</FieldLabel>
<FieldDescription id="field-responsive-name-hint">On invoices, receipts and the portal.</FieldDescription>
</FieldContent>
<Input
id="field-responsive-name"
defaultValue="Northwind Analytics"
aria-describedby="field-responsive-name-hint"
className="@md/field-group:w-56!"
/>
</Field>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="field-responsive-tax">Tax ID</FieldLabel>
<FieldDescription id="field-responsive-tax-hint">Printed under the address on every invoice.</FieldDescription>
</FieldContent>
<Input
id="field-responsive-tax"
defaultValue="IE6388047V"
aria-describedby="field-responsive-tax-hint"
className="font-mono @md/field-group:w-56!"
/>
</Field>
</FieldGroup>
)
}A fieldset
The lines of one address gathered under a legend that names them and a description that says what they are for.
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// A fieldset gathers the lines of one address under a legend that names them
// together; the pair that belongs on one line shares a row.
export default function FieldFieldset() {
return (
<FieldSet className="w-full max-w-md" aria-describedby="field-fieldset-hint">
<FieldLegend>Billing address</FieldLegend>
<FieldDescription id="field-fieldset-hint">Printed on invoices. It sets the VAT we charge.</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="field-fieldset-street">Street</FieldLabel>
<Input id="field-fieldset-street" autoComplete="address-line1" defaultValue="4 Grand Canal Square" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="field-fieldset-city">City</FieldLabel>
<Input id="field-fieldset-city" autoComplete="address-level2" defaultValue="Dublin" />
</Field>
<Field>
<FieldLabel htmlFor="field-fieldset-postcode">Postcode</FieldLabel>
<Input id="field-fieldset-postcode" autoComplete="postal-code" defaultValue="D02 X285" />
</Field>
</div>
</FieldGroup>
</FieldSet>
)
}Choice cards
A FieldLabel wrapping a Field makes the card the radio's label. Chosen is a tint, and each radio is named by its title alone.
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const REGIONS = [
{ value: "eu", title: "European Union", hint: "Frankfurt, with a standby copy in Dublin." },
{ value: "us", title: "United States", hint: "Virginia, with a standby copy in Oregon." },
]
// A FieldLabel wrapping a Field turns the whole card into the radio's label.
// The chosen card is a tint, never a stroke; each radio is named by its title
// and described by its line, rather than named by all of it.
export default function FieldChoiceCards() {
return (
<FieldSet className="w-full max-w-md">
<FieldLegend id="field-choice-cards-legend" variant="label">
Data residency
</FieldLegend>
<FieldDescription id="field-choice-cards-hint">Where this workspace's data lives. It can't move later.</FieldDescription>
<RadioGroup aria-labelledby="field-choice-cards-legend" aria-describedby="field-choice-cards-hint" defaultValue="eu">
{REGIONS.map((region) => (
<FieldLabel key={region.value} htmlFor={`field-choice-cards-${region.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle id={`field-choice-cards-${region.value}-title`}>{region.title}</FieldTitle>
<FieldDescription id={`field-choice-cards-${region.value}-hint`}>{region.hint}</FieldDescription>
</FieldContent>
<RadioGroupItem
id={`field-choice-cards-${region.value}`}
value={region.value}
aria-labelledby={`field-choice-cards-${region.value}-title`}
aria-describedby={`field-choice-cards-${region.value}-hint`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
)
}With a separator
Two ways to the same end — invite an address or share a link — divided by a labelled rule.
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSeparator } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const LINK = "https://app.northwind.example/join/7Hq2kL9x"
// Two ways to the same end, divided by a labelled rule: invite one address, or
// hand out a link. The rule's word sits on the page's plane, over the line.
export default function FieldSeparatorExample() {
return (
<FieldGroup className="w-full max-w-sm">
<Field>
<FieldLabel htmlFor="field-separator-email">Invite by email</FieldLabel>
<div className="flex gap-2">
<Input id="field-separator-email" type="email" autoComplete="off" placeholder="name@blueharbor.example" />
<Button variant="outline">Invite</Button>
</div>
</Field>
<FieldSeparator>or</FieldSeparator>
<Field>
<FieldLabel htmlFor="field-separator-link">Share a join link</FieldLabel>
<div className="flex gap-2">
<Input id="field-separator-link" readOnly value={LINK} aria-describedby="field-separator-link-hint" className="font-mono text-xs md:text-xs" />
<CopyButton value={LINK} label="Copy join link" variant="outline" />
</div>
<FieldDescription id="field-separator-link-hint">Anyone with the link joins as a Viewer until 1 October.</FieldDescription>
</Field>
</FieldGroup>
)
}A form
The parts together: a legend over fields of three kinds, a horizontal checkbox field, and one status line for the save.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
// The pieces together: a legend over fields of three kinds, the short ones
// turned horizontal, and one status line for the save.
export default function FieldForm() {
const [saved, setSaved] = React.useState(false)
return (
<form
className="w-full max-w-md"
onChange={() => setSaved(false)}
onSubmit={(event) => {
event.preventDefault()
setSaved(true)
}}
>
<FieldSet>
<FieldLegend>Invoice defaults</FieldLegend>
<FieldGroup className="gap-4">
<Field orientation="horizontal">
<FieldLabel htmlFor="field-form-terms">Payment terms, in days</FieldLabel>
<Input id="field-form-terms" type="number" min={0} max={90} defaultValue={30} className="w-20 tabular-nums" />
</Field>
<Field>
<FieldLabel htmlFor="field-form-footer">Footer note</FieldLabel>
<Textarea id="field-form-footer" defaultValue="Northwind Analytics Ltd · 4 Grand Canal Square, Dublin" className="min-h-14" />
</Field>
<Field orientation="horizontal">
<Checkbox id="field-form-pdf" defaultChecked />
<FieldLabel htmlFor="field-form-pdf" className="font-normal">
Attach a PDF to every invoice email
</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Button type="submit">Save defaults</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{saved ? "Saved. New invoices use these." : null}
</p>
</div>
</form>
)
}Right to left
An Arabic fieldset whose hint and error list start on the right, with the bullets at the right of their lines.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const RULES = [
{ ok: (v: string) => v.length >= 8, message: "استخدم ثمانية أحرف على الأقل." },
{ ok: (v: string) => /\p{Nd}/u.test(v), message: "أضف رقمًا واحدًا على الأقل." },
]
// An Arabic fieldset: the legend, the hint and the error list all start on the
// right, and the list's bullets sit on the right of their lines. A digit is any
// digit — ٣ counts as much as 3.
export default function FieldRtl() {
const [password, setPassword] = React.useState("شمال")
const errors = RULES.filter((rule) => !rule.ok(password)).map((rule) => ({ message: rule.message }))
return (
<FieldSet dir="rtl" lang="ar" className="w-full max-w-sm">
<FieldLegend variant="label">الأمان</FieldLegend>
<FieldGroup className="gap-4">
<Field data-invalid={errors.length > 0 || undefined}>
<FieldLabel htmlFor="field-rtl-password">كلمة مرور جديدة</FieldLabel>
<Input
id="field-rtl-password"
type="password"
value={password}
autoComplete="new-password"
aria-invalid={errors.length > 0 || undefined}
aria-describedby={errors.length > 0 ? "field-rtl-password-hint field-rtl-password-error" : "field-rtl-password-hint"}
onChange={(event) => setPassword(event.target.value)}
/>
<FieldDescription id="field-rtl-password-hint">تُطلب عند تسجيل الدخول من جهاز جديد.</FieldDescription>
<FieldError id="field-rtl-password-error" errors={errors} />
</Field>
</FieldGroup>
</FieldSet>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | "responsive" | "vertical" | Label above the control, beside it, or above it until the field group is md wide. |
| FieldError.errors | Array<{ message?: string } | undefined> | — | Validation messages, deduplicated; one renders as a line and several as a list, in an alert region. |
| FieldLegend.variant | "legend" | "label" | "legend" | A fieldset's heading at legend size, or at label size for a set inside a form. |
Dependencies
Source
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
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 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",
{
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 (
<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,
...props
}: React.ComponentProps<typeof Label>) {
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]]:not-has-[:disabled,[data-disabled],[data-checked]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[[data-checked]]:bg-brand-muted *:data-[slot=field]:p-2.5",
// A choice card takes the kit's one focus outline around the whole
// card, and the control inside it draws none of its own.
"has-[>[data-slot=field]]:has-[:focus-visible]:focus-outline has-[>[data-slot=field]]:[&_:is([role=checkbox],[role=radio],[role=switch])]:outline-none",
"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 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-start text-sm leading-normal font-normal text-muted-foreground group-has-data-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-primary",
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="ms-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}