Field
Labels, descriptions, red-pen errors, fieldsets and choice cards: shadcn's field layout, drawn.
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
export default function FieldDemo() {
return (
<form className="w-full max-w-lg">
<FieldGroup>
<FieldSet>
<FieldLegend>Book a call</FieldLegend>
<FieldDescription>Thirty minutes, no slides.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel htmlFor="form-name">Name</FieldLabel>
<Input id="form-name" placeholder="Ada Lovelace" seed="form-name" />
</Field>
<Field data-invalid>
<FieldLabel htmlFor="form-email">Email</FieldLabel>
<Input id="form-email" type="email" defaultValue="ada@" aria-invalid seed="form-email" />
<FieldError>That email is missing its domain.</FieldError>
</Field>
<Field>
<FieldLabel htmlFor="form-about">What's it about?</FieldLabel>
<Textarea id="form-about" placeholder="A sentence or two is plenty." seed="form-about" />
</Field>
</FieldGroup>
</FieldSet>
<FieldSeparator>When</FieldSeparator>
<FieldSet>
<FieldLegend variant="label">Time of day</FieldLegend>
<RadioGroup defaultValue="morning">
{["Morning", "Afternoon"].map((t) => (
<Field key={t} orientation="horizontal">
<RadioGroupItem value={t.toLowerCase()} id={`form-${t}`} seed={`form-${t}`} />
<FieldLabel htmlFor={`form-${t}`}>{t}</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="form-remind">Remind me</FieldLabel>
<FieldDescription>An email the day before.</FieldDescription>
</FieldContent>
<Switch id="form-remind" defaultChecked seed="form-remind" />
</Field>
<Field orientation="horizontal">
<Checkbox id="form-terms" seed="form-terms" />
<FieldLabel htmlFor="form-terms">I've read the small print</FieldLabel>
</Field>
<Field orientation="horizontal">
<Button type="submit" seed="form-submit">
Send
</Button>
<Button variant="ghost" type="reset" seed="form-reset">
Clear
</Button>
</Field>
</FieldGroup>
</form>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ fieldOr copy the source
"use client";
import { useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } 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";
import { usePen, type Pen } from "@/hooks/use-ink-box";
import { InkOutline } from "@/lib/ink-outline";
function FieldSet({ className, ...props }: ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn("flex flex-col gap-5 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className)}
{...props}
/>
);
}
function FieldLegend({ className, variant = "legend", ...props }: ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn("mb-2 data-[variant=label]:text-base data-[variant=legend]:text-lg data-[variant=legend]:font-bold", className)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-6 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-0.5",
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-0.5",
},
},
defaultVariants: {
orientation: "vertical",
},
});
function Field({ className, orientation = "vertical", ...props }: 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 }: 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} />;
}
/**
* A field's label. Wrapping a whole Field turns it into a choice card: a
* pencilled box drawn around it that inks over when its control is checked.
*/
function FieldLabel({
className,
children,
seed,
roughness,
passes,
radius,
corners,
draw,
weight,
speed,
...props
}: ComponentProps<typeof Label> & Omit<Pen, "fill" | "shadow"> & { seed?: string | number }) {
const pen = usePen({ roughness, passes, radius, corners, draw, weight, speed });
// A card is a label wrapping a Field. That's read from the DOM (children
// from a server component can't be compared by type); the layout itself
// comes from CSS, so it's right before this runs.
const ref = useRef<HTMLLabelElement>(null);
const [card, setCard] = useState(false);
useLayoutEffect(() => setCard(!!ref.current?.querySelector(":scope > [data-slot=field]")), []);
return (
<Label
ref={ref}
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]]:relative has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:cursor-pointer has-[>[data-slot=field]]:flex-col *:data-[slot=field]:p-3",
className,
)}
{...props}
>
{card && (
<InkOutline
pen={pen}
seed={seed}
estimate={[260, 76]}
maxRadius={18}
className="text-ink-line transition-colors duration-(--dur-hover) group-[:hover:not(:has([data-checked]))]/field-label:text-ink-3 group-has-data-checked/field-label:text-ink"
/>
)}
{children}
</Label>
);
}
function FieldTitle({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn("flex w-fit items-center gap-2 text-base font-bold group-data-[disabled=true]/field:opacity-50", className)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal text-ink-3 group-data-[orientation=horizontal]/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:decoration-ink-4 [&>a]:underline-offset-4 [&>a:hover]:decoration-ink",
className,
)}
{...props}
/>
);
}
function FieldSeparator({ children, className, ...props }: ComponentProps<"div"> & { children?: ReactNode }) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn("relative -my-2 flex h-6 items-center gap-3 text-sm", className)}
{...props}
>
{/* The rule breaks around its label rather than running under a patch of paper. */}
<Separator className="flex-1" />
{children && (
<>
<span className="shrink-0 text-ink-3" data-slot="field-separator-content">
{children}
</span>
<Separator className="flex-1" />
</>
)}
</div>
);
}
/** Errors in red pen. */
function FieldError({ className, children, errors, ...props }: ComponentProps<"div"> & { errors?: Array<{ message?: string } | undefined> }) {
const content = useMemo(() => {
if (children) return children;
if (!errors?.length) return null;
const unique = [...new Map(errors.map((error) => [error?.message, error])).values()];
if (unique.length === 1) return unique[0]?.message;
return <ul className="ml-4 flex list-disc flex-col gap-1">{unique.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 text-destructive", className)} {...props}>
{content}
</div>
);
}
export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle };Usage
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>We only write when it matters.</FieldDescription>
<FieldError>That email is missing its domain.</FieldError>
</Field>API reference
- Field orientation
"vertical" | "horizontal" | "responsive"default"vertical" - Label above the control, beside it, or beside it once the group is wide enough.
- FieldLegend variant
"legend" | "label"default"legend" - A fieldset title, or one sized like a label.
- FieldError errors
{ message?: string }[] - Shows each unique message; children win if given.