Checkbox
Un <input type="checkbox"> real, no un div con role — funciona con formularios, lectores de pantalla y :indeterminate nativos. Encima va la parte visible: un cuadrado de 4px de radio (más chico que el resto de la librería porque el control mismo es chico) que se llena de color solo cuando hay algo que confirmar.
Playground
<Checkbox defaultChecked />Variantes
En reposo todas las variantes son el mismo hairline neutro — el color solo aparece marcado o indeterminado, igual que Button no se pinta hasta que hay una acción real. success / danger / warning / info son para cuando marcar la casilla comunica algo por sí mismo ("aceptar el riesgo", "confirmar el borrado en lote") — no una paleta de acento para elegir a gusto. Ver Colores.
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
function CheckIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
data-icon="check"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
);
}
function MinusIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
className={className}
aria-hidden="true"
data-icon="dash"
>
<path d="M5 12h14" />
</svg>
);
}
type CheckboxSize = "sm" | "md" | "lg";
type CheckboxVariant = "default" | "success" | "danger" | "warning" | "info";
const sizeClass: Record<CheckboxSize, string> = {
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
};
const iconSizeClass: Record<CheckboxSize, string> = {
sm: "h-2.5 w-2.5",
md: "h-3 w-3",
lg: "h-3.5 w-3.5",
};
// el color solo aparece marcado/indeterminado — en reposo todas las
// variantes son el mismo hairline neutro, igual que Button no se pinta
// hasta que hay una acción
const variantClass: Record<CheckboxVariant, string> = {
default:
"[&:has(:checked)]:border-primary [&:has(:checked)]:bg-primary [&:has(:indeterminate)]:border-primary [&:has(:indeterminate)]:bg-primary text-primary-foreground",
success:
"[&:has(:checked)]:border-success [&:has(:checked)]:bg-success [&:has(:indeterminate)]:border-success [&:has(:indeterminate)]:bg-success text-success-foreground",
danger:
"[&:has(:checked)]:border-danger [&:has(:checked)]:bg-danger [&:has(:indeterminate)]:border-danger [&:has(:indeterminate)]:bg-danger text-danger-foreground",
warning:
"[&:has(:checked)]:border-warning [&:has(:checked)]:bg-warning [&:has(:indeterminate)]:border-warning [&:has(:indeterminate)]:bg-warning text-warning-foreground",
info: "[&:has(:checked)]:border-info [&:has(:checked)]:bg-info [&:has(:indeterminate)]:border-info [&:has(:indeterminate)]:bg-info text-info-foreground",
};
const boxVariants = cva(
cn(
"relative inline-flex shrink-0 items-center justify-center rounded-sm border border-border bg-background transition-colors duration-150",
EASE,
"hover:border-primary/40",
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background",
"has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50",
"has-[[aria-invalid=true]]:border-danger",
// el ícono entra con un pop corto (opacity + scale) en vez de aparecer
// de golpe — mismo idioma de entrada que el resto de la librería
"[&_svg]:opacity-0 [&_svg]:scale-50 [&_svg]:transition-all [&_svg]:duration-150",
"[&:has(:checked)_[data-icon=check]]:opacity-100 [&:has(:checked)_[data-icon=check]]:scale-100",
"[&:has(:indeterminate)_[data-icon=dash]]:opacity-100 [&:has(:indeterminate)_[data-icon=dash]]:scale-100",
),
{
variants: {
size: sizeClass,
variant: variantClass,
},
defaultVariants: {
size: "md",
variant: "default",
},
},
);
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "type" | "onChange" | "checked" | "defaultChecked">,
VariantProps<typeof boxVariants> {
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
/** ni marcado ni vacío — "algunos, no todos". Se limpia solo apenas el usuario clickea, como cualquier checkbox nativo */
indeterminate?: boolean;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, size = "md", variant, checked, defaultChecked, onCheckedChange, indeterminate = false, disabled, ...props }, ref) => {
const innerRef = React.useRef<HTMLInputElement>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);
React.useEffect(() => {
if (innerRef.current) innerRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<span className={cn(boxVariants({ size, variant }), className)}>
<input
ref={innerRef}
type="checkbox"
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
onChange={(e) => onCheckedChange?.(e.target.checked)}
className="absolute inset-0 h-full w-full cursor-pointer appearance-none disabled:cursor-not-allowed"
{...props}
/>
<CheckIcon className={cn(iconSizeClass[size ?? "md"], "pointer-events-none absolute")} />
<MinusIcon className={cn(iconSizeClass[size ?? "md"], "pointer-events-none absolute")} />
</span>
);
},
);
Checkbox.displayName = "Checkbox";Código real de packages/ui/src/checkbox.tsx.
Tamaños
Tres, no cuatro — a esta escala un xs adicional no resuelve nada que sm no resuelva ya.
Estados
El ícono entra con un pop corto (opacidad + escala, 150ms) en vez de aparecer de golpe — indeterminate es una prop aparte porque HTML no tiene un atributo indeterminate declarativo: se setea vía ref, como cualquier checkbox nativo con selección parcial ("algunos ítems marcados, no todos").
const allChecked = items.every((i) => i.done);
const someChecked = items.some((i) => i.done);
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onCheckedChange={(checked) => setAll(checked)}
/>aria-invalid tiñe el borde en danger — mismo mecanismo que Input, no un prop error aparte.
Con label
Checkbox no incluye label — se compone con HTML normal. Envolver ambos en un <label> hace que clickear el texto también marque la casilla, sin JS extra.
<label className="flex items-center gap-2 text-sm">
<Checkbox defaultChecked />
Recordar sesión
</label>
<div className="flex gap-2.5">
<Checkbox id="tos" className="mt-0.5" />
<label htmlFor="tos" className="flex flex-col gap-0.5 text-sm">
<span>Acepto los términos</span>
<span className="text-xs text-muted-foreground">Puedes leerlos antes de continuar.</span>
</label>
</div>Requerido
No hay un prop required visual — el error es tu estado, marcado con aria-invalid recién después de un intento real de continuar, no desde el primer render. Intenta clickear Continuar sin marcar la casilla.
const [checked, setChecked] = useState(false);
const [submitted, setSubmitted] = useState(false);
const invalid = submitted && !checked;
<div className="flex gap-2.5">
<Checkbox
id="tos"
checked={checked}
aria-invalid={invalid}
aria-describedby={invalid ? "tos-error" : undefined}
onCheckedChange={(value) => {
setChecked(value);
if (value) setSubmitted(false);
}}
/>
<label htmlFor="tos">Acepto los términos y condiciones</label>
</div>
{invalid && <p id="tos-error" className="text-danger">Tienes que aceptar los términos para continuar.</p>}
<Button onClick={() => setSubmitted(true)}>Continuar</Button>
{/* el error solo aparece después de intentar continuar sin aceptar —
no de entrada, que sería castigar al usuario antes de que haga nada */}Controlado
const [checked, setChecked] = useState(false);
<Checkbox checked={checked} onCheckedChange={setChecked} />Uso
import { Checkbox } from "./components/ui/checkbox";
<Checkbox defaultChecked />API
| Prop | Tipo | Default |
|---|---|---|
| variant | default | success | danger | warning | info | default |
| size | sm | md | lg | md |
| checked / defaultChecked | boolean | — |
| onCheckedChange | (checked: boolean) => void | — |
| indeterminate | boolean | false |
| aria-invalid | boolean | false |
El resto son los atributos nativos de <input type="checkbox"> — se pasan directo (disabled, name, value, required, etc).