Skip to content
goodtabs UI
BrowseBrowse components

Labeled field

A label, hint, error line and required mark around any form control.

npx shadcn@latest add https://ui.goodtabs.co/r/labeled-field.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/labeled-field

A label over an input. Clicking the label focuses the input.

With an error

The error line is wired to the input through aria-describedby and aria-invalid.

With a hint, required

Letters, numbers and dashes. Shown in your public URL.

A hint under the input and an asterisk with a screen reader only required.

Usage

import { LabeledField, fieldDescribedBy } from "@/components/ui/labeled-field"
import { Input } from "@/components/ui/input"

<LabeledField id="email" label="Email" hint="We only use it to sign you in." error={error} required>
  <Input id="email" type="email" aria-invalid={!!error} aria-describedby={fieldDescribedBy("email", { hint: true, error: !!error })} />
</LabeledField>

Props

PropTypeDefaultDescription
idstringThe control's id. Pass the same id to the control.
labelReactNodeVisible label text.
hintReactNodeHelp text under the control, with id `${id}-hint`.
errorReactNodeError text under the control, with id `${id}-error` and role=alert.
requiredbooleanfalseAdds the asterisk and sr-only required.
childrenReactNodeThe control, with id and aria-describedby set by you.

Source

labeled-field/labeled-field.tsx
import * as React from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";

type LabeledFieldProps = Omit<React.ComponentProps<"div">, "id" | "children"> & {
  /** The control's id. The label points at it; the hint and error ids derive from it. */
  id: string;
  /** Visible label text. */
  label: React.ReactNode;
  /** Help text under the control, rendered with id `${id}-hint`. */
  hint?: React.ReactNode;
  /** Error text under the control, rendered with id `${id}-error` and role=alert. */
  error?: React.ReactNode;
  /** Adds an asterisk and a screen reader only "required" to the label. */
  required?: boolean;
  /** The control. Give it id={id} and aria-describedby={fieldDescribedBy(id, { hint, error })}. */
  children: React.ReactNode;
};

/**
 * Builds the aria-describedby string for a control inside a LabeledField.
 * Error first, then hint, so the problem is read before the help.
 */
export function fieldDescribedBy(id: string, parts: { hint?: boolean; error?: boolean }): string | undefined {
  const ids = [parts.error ? `${id}-error` : null, parts.hint ? `${id}-hint` : null].filter(Boolean);
  return ids.length ? ids.join(" ") : undefined;
}

/**
 * A label, a control, and an optional hint and error line.
 * The consumer passes id and aria-describedby to the control; see fieldDescribedBy.
 */
export function LabeledField({ id, label, hint, error, required, className, children, ...props }: LabeledFieldProps) {
  return (
    <div
      data-slot="labeled-field"
      data-invalid={error ? "" : undefined}
      className={cn("flex w-full flex-col gap-1.5", className)}
      {...props}
    >
      <Label htmlFor={id} className="w-fit gap-0.5">
        {label}
        {required ? (
          <>
            <span aria-hidden="true" className="text-muted-foreground">
              *
            </span>
            <span className="sr-only">required</span>
          </>
        ) : null}
      </Label>
      {children}
      {error ? (
        <p id={`${id}-error`} role="alert" className="m-0 text-xs text-destructive">
          {error}
        </p>
      ) : null}
      {hint ? (
        <p id={`${id}-hint`} className="m-0 text-xs text-muted-foreground">
          {hint}
        </p>
      ) : null}
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/labeled-field/prompt.md, or the Copy AI prompt button above.

When to use

  • Around any shadcn control that needs a label: Input, Textarea, a select trigger, an OTP field
  • Forms where hints and validation errors should be read by screen readers in the right order
  • Settings pages with a label over each field

When not to use

  • For a checkbox or switch with its label beside it; those label themselves
  • Around a group of radios or checkboxes; use a fieldset with a legend
  • When the control already renders its own label

Agent notes

  • It does not clone the child. Pass id={id} to the control yourself, and aria-describedby from fieldDescribedBy(id, { hint, error })
  • Set aria-invalid on the control when error is present; the field only renders the message
  • Server component: no state, so it works in server and client trees alike
  • The required mark is visual plus an sr-only word; if the control also has the required attribute, screen readers may say it twice