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.
Search across all documentation pages
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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.
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.
"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.
"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.
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.
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.
"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:
as prop -- 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.required and optional are guarded so only one can render at a time, preventing confusing conflicting indicators.<button> with onFocus/onBlur handlers, so keyboard users can access the tooltip by tabbing to it. The aria-describedby links the button to the tooltip content.text-gray-400 when the associated control is disabled, reinforcing the inactive state visually.srOnly is true, the component returns early with just the screen-reader-only class, avoiding unnecessary DOM elements for the tooltip or indicators.Missing htmlFor and id pairing -- if the label's htmlFor does not match any element's id, 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 the as="span" pattern if composing labels inside a larger label wrapper.
Asterisk announced by screen readers -- if the asterisk * lacks aria-hidden="true", screen readers will announce "asterisk" after the label text. Always hide decorative indicators and rely on aria-required on the input instead.
Dynamic IDs causing hydration mismatch -- using Math.random() or Date.now() for IDs generates different values on server and client, causing React hydration errors. Use useId() 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. Use e.preventDefault() on the tooltip button or place the tooltip outside the label.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥