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
That name is taken.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | The control's id. Pass the same id to the control. | |
| label | ReactNode | Visible label text. | |
| hint | ReactNode | Help text under the control, with id `${id}-hint`. | |
| error | ReactNode | Error text under the control, with id `${id}-error` and role=alert. | |
| required | boolean | false | Adds the asterisk and sr-only required. |
| children | ReactNode | The 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