Skip to content
Ballpoint
Contents

Field

Labels, descriptions, red-pen errors, fieldsets and choice cards: shadcn's field layout, drawn.

Book a call

Thirty minutes, no slides.

When
Time of day

An email the day before.

Installation

pnpm dlx shadcn@latest add @ballpoint/field
Or copy the source
ui/field.tsx
"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

tsx
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.