Label
An accessible text label that pairs with form controls via htmlFor, providing clear identification and click-to-focus behavior for inputs, selects, and other interactive elements.
Use Cases
- Label text inputs, selects, and textareas in forms
- Indicate required fields with a visual asterisk
- Mark optional fields to reduce user confusion
- Provide inline help via a tooltip icon next to the label
- Wrap label and input together as a reusable form field component
- Display validation error styling alongside the label text
- Associate labels with custom controls like switches or sliders
Simplest Implementation
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
}
export function Label({ htmlFor, children }: LabelProps) {
return (
<label
htmlFor={htmlFor}
className="block text-sm font-medium text-gray-700"
>
{children}
</label>
);
}A minimal label component that renders a styled <label> element. The htmlFor prop connects it to a form control by id, so clicking the label focuses the associated input. This component does not need "use client" because it has no state or event handlers.
Variations
With Required Indicator
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
required?: boolean;
}
export function Label({ htmlFor, children, required = false }: LabelProps) {
return (
<label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700">
{children}
{required && <span className="ml-1 text-red-500" aria-hidden="true">*</span>}
</label>
);
}The red asterisk signals a required field visually. The aria-hidden="true" prevents screen readers from announcing "asterisk" -- the input itself should use aria-required="true" or the required attribute to communicate the requirement programmatically.
With Optional Indicator
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
optional?: boolean;
}
export function Label({ htmlFor, children, optional = false }: LabelProps) {
return (
<label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700">
{children}
{optional && (
<span className="ml-1.5 text-xs font-normal text-gray-400">(optional)</span>
)}
</label>
);
}When most fields in a form are required, marking the few optional ones is less noisy than adding asterisks to every required field. The smaller, lighter text clearly distinguishes the indicator from the label content.
With Tooltip Help Icon
"use client";
import { useState } from "react";
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
tooltip: string;
}
export function Label({ htmlFor, children, tooltip }: LabelProps) {
const [showTooltip, setShowTooltip] = useState(false);
return (
<label htmlFor={htmlFor} className="inline-flex items-center gap-1.5 text-sm font-medium text-gray-700">
{children}
<span
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<svg
className="h-4 w-4 cursor-help text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20 10 10 0 000-20z"
/>
</svg>
{showTooltip && (
<span className="absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs font-normal text-white">
{tooltip}
</span>
)}
</span>
</label>
);
}A help icon next to the label reveals additional context on hover. The tooltip is positioned above the icon with bottom-full and centered with -translate-x-1/2. The tooltip span is nested inside the label but uses onMouseEnter/onMouseLeave on its own wrapper to scope the hover area.
As Form Field Wrapper
"use client";
import { useId } from "react";
interface FormFieldProps {
label: string;
error?: string;
required?: boolean;
children: (id: string) => React.ReactNode;
}
export function FormField({ label, error, required, children }: FormFieldProps) {
const id = useId();
return (
<div className="space-y-1">
<label htmlFor={id} className="block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500" aria-hidden="true">*</span>}
</label>
{children(id)}
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
// Usage:
// <FormField label="Email" required error={errors.email}>
// {(id) => (
// <input
// id={id}
// type="email"
// className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
// />
// )}
// </FormField>The render-prop pattern passes the generated id to the child input, ensuring the label and input are always connected without the consumer managing IDs manually. The error message uses role="alert" so screen readers announce it immediately.
With Error Styling
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
error?: boolean;
required?: boolean;
}
export function Label({ htmlFor, children, error = false, required = false }: LabelProps) {
return (
<label
htmlFor={htmlFor}
className={`block text-sm font-medium ${
error ? "text-red-600" : "text-gray-700"
}`}
>
{children}
{required && (
<span className={`ml-1 ${error ? "text-red-600" : "text-red-500"}`} aria-hidden="true">
*
</span>
)}
</label>
);
}When a field has a validation error, the label turns red to draw attention to the problem area. This pairs with a red border on the input and an error message below it. The asterisk color also shifts to match the error state for visual consistency.
Visually Hidden Label
interface LabelProps {
htmlFor: string;
children: React.ReactNode;
srOnly?: boolean;
}
export function Label({ htmlFor, children, srOnly = false }: LabelProps) {
return (
<label
htmlFor={htmlFor}
className={
srOnly
? "sr-only"
: "block text-sm font-medium text-gray-700"
}
>
{children}
</label>
);
}The sr-only class hides the label visually while keeping it in the accessibility tree. This is useful for search inputs or icon buttons where a visible label would clutter the design but screen readers still need descriptive text.
Complex Implementation
"use client";
import { forwardRef, useId, useState } from "react";
interface LabelProps {
htmlFor?: string;
children: React.ReactNode;
required?: boolean;
optional?: boolean;
error?: boolean;
disabled?: boolean;
tooltip?: string;
srOnly?: boolean;
className?: string;
as?: "label" | "span";
}
export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
{
htmlFor,
children,
required = false,
optional = false,
error = false,
disabled = false,
tooltip,
srOnly = false,
className,
as: Component = "label",
},
ref
) {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipId = useId();
if (srOnly) {
return (
<Component
ref={ref as React.Ref<HTMLLabelElement>}
htmlFor={Component === "label" ? htmlFor : undefined}
className="sr-only"
>
{children}
</Component>
);
}
const textColor = (() => {
if (disabled) return "text-gray-400";
if (error) return "text-red-600";
return "text-gray-700";
})();
return (
<Component
ref={ref as React.Ref<HTMLLabelElement>}
htmlFor={Component === "label" ? htmlFor : undefined}
className={`inline-flex items-center gap-1.5 text-sm font-medium ${textColor} ${className ?? ""}`}
>
<span>{children}</span>
{required && !optional && (
<span className={error ? "text-red-600" : "text-red-500"} aria-hidden="true">
*
</span>
)}
{optional && !required && (
<span className="text-xs font-normal text-gray-400">(optional)</span>
)}
{tooltip && (
<span
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
>
<button
type="button"
tabIndex={0}
aria-describedby={showTooltip ? tooltipId : undefined}
className="inline-flex items-center text-gray-400 hover:text-gray-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:rounded"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20 10 10 0 000-20z"
/>
</svg>
</button>
{showTooltip && (
<span
id={tooltipId}
role="tooltip"
className="absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs font-normal text-white shadow-lg"
>
{tooltip}
</span>
)}
</span>
)}
</Component>
);
});Key aspects:
- Polymorphic
asprop -- renders as either a<label>or<span>depending on context. When used inside a<label>wrapper, rendering as a<span>avoids nested labels which are invalid HTML. - forwardRef -- allows parent components to access the underlying DOM element for focus management or measurement.
- Mutually exclusive indicators --
requiredandoptionalare guarded so only one can render at a time, preventing confusing conflicting indicators. - Tooltip with keyboard support -- the help icon is a focusable
<button>withonFocus/onBlurhandlers, so keyboard users can access the tooltip by tabbing to it. Thearia-describedbylinks the button to the tooltip content. - Disabled state -- the label dims to
text-gray-400when the associated control is disabled, reinforcing the inactive state visually. - sr-only fast path -- when
srOnlyis true, the component returns early with just the screen-reader-only class, avoiding unnecessary DOM elements for the tooltip or indicators. - Error color propagation -- both the label text and the required asterisk shift to red in the error state, creating a clear visual connection to the input's error border.
Gotchas
-
Missing
htmlForandidpairing -- if the label'shtmlFordoes not match any element'sid, clicking the label does nothing. Always ensure the IDs match, or wrap the input inside the label element. -
Nested
<label>elements -- wrapping a label component inside another<label>is invalid HTML and causes unpredictable behavior. Use theas="span"pattern if composing labels inside a larger label wrapper. -
Asterisk announced by screen readers -- if the asterisk
*lacksaria-hidden="true", screen readers will announce "asterisk" after the label text. Always hide decorative indicators and rely onaria-requiredon the input instead. -
Dynamic IDs causing hydration mismatch -- using
Math.random()orDate.now()for IDs generates different values on server and client, causing React hydration errors. UseuseId()for SSR-safe unique IDs. -
Label not updating with error state -- if the label color does not change when a validation error appears, the user may not notice which field has the problem. Always pass the error state to the label, not just the input.
-
Tooltip inside label intercepts clicks -- interactive elements inside a
<label>can cause the label click to fire on the tooltip button instead of focusing the associated input. Usee.preventDefault()on the tooltip button or place the tooltip outside the label.