Casilla de verificación
Un interruptor para valores booleanos, utilizado individualmente para controles de opt-in o en grupos para selección múltiple. Construido con entradas de casilla de verificación nativas y estilo Tailwind para accesibilidad y consistencia.
Casos de uso
- Términos de servicio y acuerdos de política de privacidad
- Preferencias de notificación por correo electrónico y alternadores de opt-in
- Filtros de selección múltiple en páginas de listado de productos
- "Recuérdame" en formularios de inicio de sesión
- Selección masiva en tablas de datos con "seleccionar todo"
- Alternadores de características en paneles de configuración
- Casillas de verificación de tareas completadas en listas de tareas
Implementación más simple
"use client";
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Checkbox({ label, checked, onChange }: CheckboxProps) {
return (
<label className="inline-flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
);
}Una casilla de verificación mínima con una etiqueta de texto. El callback onChange devuelve un booleano directamente, por lo que el componente padre no necesita desempaquetar e.target.checked. Envolver ambos elementos dentro de una <label> hace que toda la fila sea clickeable.
Variaciones
Casilla de verificación única
"use client";
interface CheckboxProps {
checked: boolean;
onChange: (checked: boolean) => void;
name?: string;
}
export function Checkbox({ checked, onChange, name }: CheckboxProps) {
return (
<input
type="checkbox"
name={name}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
);
}Una casilla de verificación independiente sin etiqueta, útil cuando la etiqueta se renderiza por separado (como en filas de tabla o diseños personalizados). El prop name permite que participe en envíos de formularios nativos.
Casilla de verificación con etiqueta
"use client";
import { useId } from "react";
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Checkbox({ label, checked, onChange, disabled = false }: CheckboxProps) {
const id = useId();
return (
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor={id}
className={`text-sm ${disabled ? "cursor-not-allowed text-gray-400" : "cursor-pointer text-gray-700"}`}
>
{label}
</label>
</div>
);
}Usa useId para generar un ID estable que vincula <label> e <input> a través de htmlFor. Este enfoque mantiene la etiqueta y la entrada como hermanas, dando más flexibilidad para el diseño que envolver la entrada dentro de la etiqueta.
Grupo de casillas de verificación
"use client";
interface Option {
value: string;
label: string;
}
interface CheckboxGroupProps {
label: string;
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
}
export function CheckboxGroup({ label, options, selected, onChange }: CheckboxGroupProps) {
function toggleValue(value: string) {
onChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value]
);
}
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selected.includes(opt.value)}
onChange={() => toggleValue(opt.value)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}Un grupo de casillas de verificación gestionado como un array de valores seleccionados. Los elementos <fieldset> y <legend> proporcionan agrupación semántica para lectores de pantalla. Alternar un valor lo añade a o lo elimina del array seleccionado.
Estado indeterminado
"use client";
import { useRef, useEffect } from "react";
interface IndeterminateCheckboxProps {
label: string;
checked: boolean;
indeterminate: boolean;
onChange: (checked: boolean) => void;
}
export function IndeterminateCheckbox({
label,
checked,
indeterminate,
onChange,
}: IndeterminateCheckboxProps) {
const checkboxRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
return (
<label className="inline-flex cursor-pointer items-center gap-2">
<input
ref={checkboxRef}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm font-medium text-gray-700">{label}</span>
</label>
);
}El estado indeterminado es un tercer estado visual (un guión en lugar de una marca de verificación) que solo se puede configurar mediante JavaScript, no atributos HTML. Esto se usa típicamente para una casilla "seleccionar todo" cuando solo se marcan algunos elementos secundarios. El useEffect establece indeterminate directamente en el elemento DOM.
Con texto de descripción
"use client";
import { useId } from "react";
interface CheckboxProps {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Checkbox({ label, description, checked, onChange }: CheckboxProps) {
const id = useId();
const descriptionId = `${id}-description`;
return (
<div className="flex items-start gap-3">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-describedby={descriptionId}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<div>
<label htmlFor={id} className="cursor-pointer text-sm font-medium text-gray-700">
{label}
</label>
<p id={descriptionId} className="text-sm text-gray-500">
{description}
</p>
</div>
</div>
);
}Añade una descripción secundaria debajo de la etiqueta para contexto adicional. La casilla de verificación se alinea en la parte superior del bloque de texto con items-start y mt-0.5. El atributo aria-describedby vincula la descripción a la entrada para lectores de pantalla.
Controlada con integración de formularios
"use client";
import { useActionState } from "react";
interface FormState {
message: string;
error?: string;
}
async function submitPreferences(
_prev: FormState,
formData: FormData
): Promise<FormState> {
const accepted = formData.get("terms") === "on";
if (!accepted) {
return { message: "", error: "Debes aceptar los términos para continuar." };
}
return { message: "¡Preferencias guardadas con éxito!" };
}
export function PreferencesForm() {
const [state, formAction, isPending] = useActionState(submitPreferences, {
message: "",
});
return (
<form action={formAction} className="space-y-4">
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-gray-700">Notificaciones</legend>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="email_updates"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">Actualizaciones por correo electrónico</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="marketing"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">Correos de marketing</span>
</label>
</fieldset>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="terms"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">Acepto los términos y condiciones</span>
</label>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "Guardando..." : "Guardar preferencias"}
</button>
{state.message && <p className="text-sm text-green-600">{state.message}</p>}
</form>
);
}Usa React 19 useActionState para manejar el envío del formulario. Las casillas de verificación no están controladas con atributos name para que el navegador las recopile en FormData. Una casilla marcada envía "on" como su valor; las casillas desmarcadas están completamente ausentes de los datos del formulario.
Implementación compleja
"use client";
import { forwardRef, useId, useRef, useEffect, useCallback } from "react";
type CheckboxSize = "sm" | "md" | "lg";
interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size" | "onChange"> {
label?: string;
description?: string;
error?: string;
size?: CheckboxSize;
indeterminate?: boolean;
onCheckedChange?: (checked: boolean) => void;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const sizeClasses: Record<CheckboxSize, { box: string; label: string; desc: string }> = {
sm: { box: "h-3.5 w-3.5", label: "text-xs", desc: "text-xs" },
md: { box: "h-4 w-4", label: "text-sm", desc: "text-sm" },
lg: { box: "h-5 w-5", label: "text-base", desc: "text-sm" },
};
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
{
label,
description,
error,
size = "md",
indeterminate = false,
onCheckedChange,
onChange,
disabled,
className,
id: externalId,
...rest
},
ref
) {
const generatedId = useId();
const checkboxId = externalId ?? generatedId;
const errorId = `${checkboxId}-error`;
const descriptionId = `${checkboxId}-desc`;
const internalRef = useRef<HTMLInputElement>(null);
const checkboxRef = (ref as React.RefObject<HTMLInputElement>) ?? internalRef;
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate, checkboxRef]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onCheckedChange?.(e.target.checked);
},
[onChange, onCheckedChange]
);
const hasError = !!error;
const sizes = sizeClasses[size];
const describedBy = [
description ? descriptionId : null,
hasError ? errorId : null,
]
.filter(Boolean)
.join(" ") || undefined;
return (
<div className="flex items-start gap-3">
<input
ref={checkboxRef}
id={checkboxId}
type="checkbox"
disabled={disabled}
onChange={handleChange}
aria-invalid={hasError}
aria-describedby={describedBy}
className={[
"mt-0.5 rounded border-gray-300 text-blue-600",
"focus:ring-2 focus:ring-blue-500 focus:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-50",
hasError ? "border-red-500" : "",
sizes.box,
className ?? "",
]
.filter(Boolean)
.join(" ")}
{...rest}
/>
{(label || description || hasError) && (
<div className="flex flex-col">
{label && (
<label
htmlFor={checkboxId}
className={[
"font-medium",
disabled ? "cursor-not-allowed text-gray-400" : "cursor-pointer text-gray-700",
sizes.label,
].join(" ")}
>
{label}
</label>
)}
{description && (
<p id={descriptionId} className={`text-gray-500 ${sizes.desc}`}>
{description}
</p>
)}
{hasError && (
<p id={errorId} className={`mt-0.5 text-red-600 ${sizes.desc}`} role="alert">
{error}
</p>
)}
</div>
)}
</div>
);
});Aspectos clave:
- forwardRef -- permite que los componentes padre adjunten refs para gestión de foco, librerías de formularios o acceso imperativo a la entrada de casilla de verificación subyacente.
- useId para accesibilidad -- genera un ID único estable para vincular la etiqueta, descripción y texto de error a la entrada sin requerir que el consumidor proporcione IDs.
- Soporte para estado indeterminado -- el prop
indeterminatese aplica a través deuseEffecten el elemento DOM ya que HTML no tiene atributoindeterminate. Esto permite patrones de "seleccionar todo" en tablas de datos. - API dual onChange -- soporta tanto el manejador de eventos
onChangenativo como una devolución de llamada booleana simplificadaonCheckedChange, haciéndola compatible con librerías de formularios y estado simple por igual. - Encadenamiento de aria-describedby -- vincula la casilla de verificación tanto a descripción como a elementos de texto de error para que los lectores de pantalla anuncien información contextual en el orden correcto.
- Variantes de tamaño -- tres tamaños ajustan las dimensiones de la casilla, el tamaño de la fuente de la etiqueta y el tamaño del texto de descripción juntos para consistencia visual.
- Borde de error -- cuando hay un error, el borde de la casilla se vuelve rojo además de mostrar el mensaje de error, dando retroalimentación visual y textual.
Gotchas
-
El valor de la casilla en FormData es "on", no true -- Cuando se usa envío de formularios nativos, una casilla marcada envía
"on"como su valor. Las casillas desmarcadas están completamente ausentes deFormData, no"off". UsaformData.has("name")para verificar la existencia. -
indeterminate no es un atributo HTML -- No puedes configurar el estado indeterminado a través de props JSX. Debe configurarse a través de una ref con
el.indeterminate = true. Olvidar esto lleva a que el estado indeterminado nunca aparezca. -
La casilla controlada necesita tanto checked como onChange -- Proporcionar
checkedsinonChangehace que la casilla sea de solo lectura y dispara una advertencia de React. Si quieres una casilla de solo lectura, también pasareadOnly. -
defaultChecked vs checked -- Usar
defaultCheckedhace que la casilla no esté controlada. Cambiar entredefaultCheckedycheckeden tiempo de ejecución causa comportamiento impredecible. Elige un enfoque por instancia de componente. -
Serialización de grupo de casillas -- Cuando múltiples casillas comparten el mismo
name,FormData.getAll("name")devuelve un array de strings"on". Dale a cada casilla unnameúnico o usa un atributo value:<input type="checkbox" name="colors" value="red" />. -
Área de clic demasiado pequeña -- Una casilla desnuda es un objetivo pequeño. Siempre envoltura en una
<label>o usa suficiente padding alrededor de ella para cumplir con tamaños mínimos de objetivos táctiles (al menos 44x44px en móvil).
Relacionado
- Input -- Entrada de texto con convenciones de estilo de formulario compartidas
- Grupo de radio -- Selección mutuamente excluyente para escenarios de opción única
- Button -- Botones de envío utilizados junto a casillas en formularios
- Formularios -- Patrones de formularios y estrategias de validación
- Manejo de eventos -- onChange y tipado de eventos