Forms
Checkbox
Casilla de verificación con estado indeterminado y enlace en el label
Preview
size
label
link
indeterminate
required
error
disabled
Code
import css from "./Checkbox.module.css";
import { useEffect, useRef } from "react";
import { CheckIcon } from "@phosphor-icons/react/dist/csr/Check";
import { MinusIcon } from "@phosphor-icons/react/dist/csr/Minus";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface CheckboxLink {
label: string;
href: string;
target?: string;
}
interface CheckboxProps {
size: Size | ResponsiveSize;
name?: string;
label?: string;
link?: CheckboxLink;
"aria-label"?: string;
indeterminate?: boolean;
checked: boolean;
required?: boolean;
error?: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Checkbox({
size,
name,
label,
link,
"aria-label": ariaLabel,
indeterminate,
checked,
required,
error,
disabled,
onChange,
}: CheckboxProps) {
const input = useRef<HTMLInputElement>(null);
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
onChange(ev.target.checked);
};
// A DOM property with no HTML attribute: only script can set it.
useEffect(() => {
input.current!.indeterminate = indeterminate ?? false;
}, [indeterminate]);
const Wrapper = label || link ? "label" : "span";
return (
<Wrapper
className={`
${css.checkbox}
${getSizeClasses(size)}
${checked ? css.checked : ""}
${indeterminate ? css.indeterminate : ""}
${error ? css.error : ""}
${disabled ? css.disabled : ""}
`}
>
<input
ref={input}
type="checkbox"
name={name}
checked={checked}
required={required}
disabled={disabled}
aria-label={label || link ? undefined : ariaLabel}
aria-invalid={error}
onChange={handleChange}
/>
<span className={css.checkbox_box}>
{indeterminate ? (
<MinusIcon className={css.box_icon} weight="bold" aria-hidden="true" />
) : (
<CheckIcon className={css.box_icon} weight="bold" aria-hidden="true" />
)}
</span>
{(label || link) && (
<span className={css.checkbox_label}>
{label}
{label && link && " "}
{link && (
<a
className={css.label_link}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
>
{link.label}
</a>
)}
{required && <span aria-hidden="true">*</span>}
</span>
)}
</Wrapper>
);
}
.checkbox {
position: relative;
/* Keeps the layers of the stretched input and the link
inside: loose, they cover a popover opened over them. */
isolation: isolate;
display: inline-flex;
align-items: center;
gap: var(--space-8);
/* WCAG 2.5.8: the root never measures under 24px, whatever box the step draws. */
min-width: 24px;
min-height: 24px;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text);
user-select: none;
/* The steps sit below the control ladder, which starts at 28. */
&.xs {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
&.xs_base {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_base {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_base {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_base {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_mobile {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_mobile {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_mobile {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_tablet {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_tablet {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_tablet {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_desktop {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_desktop {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_desktop {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_wide {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_wide {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_wide {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
/* Transparent and stretched over the root: every click lands on the native control. */
input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.checkbox_box {
display: flex;
align-items: center;
justify-content: center;
width: var(--checkbox-size);
height: var(--checkbox-size);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-4);
transition: background 0.2s ease, border-color 0.2s ease;
}
.box_icon {
font-size: var(--checkbox-icon);
color: var(--color-bg);
opacity: 0;
transform: scale(0.5);
transition:
opacity 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: opacity 0.2s ease;
}
}
.checkbox_label {
transition: color 0.2s ease;
}
.label_link {
/* Above the stretched input, so the link keeps its own click. */
position: relative;
z-index: 2;
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
@media (hover: hover) {
&:hover {
color: var(--color-accent);
}
}
}
&:not(:has(.checkbox_label)) {
justify-content: center;
}
&:has(.checkbox_label):has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.checkbox_label)):has(input:focus-visible) .checkbox_box {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.checked,
&.indeterminate {
.checkbox_box {
background: var(--color-accent);
border-color: var(--color-accent);
}
.box_icon {
opacity: 1;
transform: scale(1);
}
}
&.error {
.checkbox_box {
border-color: var(--color-error);
}
&.checked,
&.indeterminate {
.checkbox_box {
background: var(--color-error);
}
}
.checkbox_label {
color: var(--color-error);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import { computed, nextTick, useTemplateRef, watchEffect } from "vue";
import { PhCheck, PhMinus } from "@phosphor-icons/vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface CheckboxLink {
label: string;
href: string;
target?: string;
}
interface CheckboxProps {
size: Size | ResponsiveSize;
name?: string;
label?: string;
link?: CheckboxLink;
ariaLabel?: string;
indeterminate?: boolean;
required?: boolean;
error?: boolean;
disabled?: boolean;
}
const { size, indeterminate } = defineProps<CheckboxProps>();
const model = defineModel<boolean>({ required: true });
const input = useTemplateRef<HTMLInputElement>("input");
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
// A DOM property with no HTML attribute: only script can set it.
watchEffect(
() => {
input.value!.indeterminate = indeterminate ?? false;
},
{ flush: "post" },
);
const onChange = () => {
nextTick(() => {
input.value!.checked = model.value;
});
};
</script>
<template>
<component
:is="label || link ? 'label' : 'span'"
class="checkbox"
:class="[sizeClasses, { checked: model, indeterminate, error, disabled }]"
>
<input
ref="input"
v-model="model"
type="checkbox"
:name="name"
:required="required"
:disabled="disabled"
:aria-label="label || link ? undefined : ariaLabel"
:aria-invalid="error"
@change="onChange"
/>
<span class="checkbox_box">
<PhMinus v-if="indeterminate" class="box_icon" weight="bold" aria-hidden="true" />
<PhCheck v-else class="box_icon" weight="bold" aria-hidden="true" />
</span>
<span v-if="label || link" class="checkbox_label">
{{ label
}}<template v-if="label && link">{{ " " }}</template
><a
v-if="link"
class="label_link"
:href="link.href"
:target="link.target"
:rel="link.target === '_blank' ? 'noopener noreferrer' : undefined"
>{{ link.label }}</a
><span v-if="required" aria-hidden="true">*</span>
</span>
</component>
</template>
<style scoped>
.checkbox {
position: relative;
/* Keeps the layers of the stretched input and the link
inside: loose, they cover a popover opened over them. */
isolation: isolate;
display: inline-flex;
align-items: center;
gap: var(--space-8);
/* WCAG 2.5.8: the root never measures under 24px, whatever box the step draws. */
min-width: 24px;
min-height: 24px;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text);
user-select: none;
/* The steps sit below the control ladder, which starts at 28. */
&.xs {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
&.xs_base {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_base {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_base {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_base {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_mobile {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_mobile {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_mobile {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_tablet {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_tablet {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_tablet {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_desktop {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_desktop {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_desktop {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--checkbox-size: 16px;
--checkbox-icon: 12px;
font-size: var(--text-14);
}
&.sm_wide {
--checkbox-size: 18px;
--checkbox-icon: 14px;
font-size: var(--text-14);
}
&.md_wide {
--checkbox-size: 20px;
--checkbox-icon: 16px;
font-size: var(--text-16);
}
&.lg_wide {
--checkbox-size: 22px;
--checkbox-icon: 18px;
font-size: var(--text-16);
}
}
/* Transparent and stretched over the root: every click lands on the native control. */
input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.checkbox_box {
display: flex;
align-items: center;
justify-content: center;
width: var(--checkbox-size);
height: var(--checkbox-size);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-4);
transition: background 0.2s ease, border-color 0.2s ease;
}
.box_icon {
font-size: var(--checkbox-icon);
color: var(--color-bg);
opacity: 0;
transform: scale(0.5);
transition:
opacity 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: opacity 0.2s ease;
}
}
.checkbox_label {
transition: color 0.2s ease;
}
.label_link {
/* Above the stretched input, so the link keeps its own click. */
position: relative;
z-index: 2;
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
@media (hover: hover) {
&:hover {
color: var(--color-accent);
}
}
}
&:not(:has(.checkbox_label)) {
justify-content: center;
}
&:has(.checkbox_label):has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.checkbox_label)):has(input:focus-visible) .checkbox_box {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.checked,
&.indeterminate {
.checkbox_box {
background: var(--color-accent);
border-color: var(--color-accent);
}
.box_icon {
opacity: 1;
transform: scale(1);
}
}
&.error {
.checkbox_box {
border-color: var(--color-error);
}
&.checked,
&.indeterminate {
.checkbox_box {
background: var(--color-error);
}
}
.checkbox_label {
color: var(--color-error);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Tamaño de la casilla (caja, icono y label). Fijo o responsive. |
| name | string | — | Nombre del campo cuando vive en un formulario; sin él no se envía. |
| label | string | — | Texto junto a la casilla. Sin label ni link, queda solo la caja. |
| link | { label: string; href: string; target?: string } | — | Enlace dentro de la etiqueta, detrás del texto. Pulsarlo no marca la casilla. |
| aria-label | string | — | Nombre del control para lectores de pantalla. Requerida si no hay label ni link; con ellos se ignora. |
| indeterminate | boolean | — | Estado a medias, con un guion en la caja. Es el "seleccionar todo" cuando solo hay algunas filas marcadas. Se anuncia como "mixto". |
| required | boolean | — | Marca el campo como obligatorio (añade * al label y el required nativo). |
| error | boolean | — | Estado de error visual (borde rojo, aria-invalid). |
| disabled | boolean | — | Desactiva la casilla (no interactiva, opacidad reducida). |
| checked | boolean | required | Estado marcado (controlado). |
| onChange | (checked: boolean) => void | required | Callback con el nuevo estado en cada cambio. |