Lanzas un nuevo sistema de diseño. Marketing adora las casillas “limpias”. Aun así llegan tickets de soporte: “No puedo saber qué está seleccionado”, “El teclado no funciona”, “El lector de pantalla dice ‘en blanco’”. Mientras tanto tu cerebro de SRE está gritando: acabamos de desplegar una regresión UI con la misma energía que una mala configuración—silenciosa al principio, luego cara.
Los controles de formulario personalizados no son difíciles. Los controles que mienten sí lo son. Este artículo trata sobre construir casillas y radios personalizados con CSS puro manteniendo la semántica nativa, el comportamiento del teclado y la supervivencia en modos de alto contraste. Sin trucos JS, sin teatro de accesibilidad.
No negociables: qué significa realmente “accesible” para casillas/radios
Las casillas y los radios son aburridos a propósito. Son de las piezas de interacción más estandarizadas de toda la plataforma web. El navegador te da semántica, comportamiento del teclado, gestión del foco, conexión con APIs de accesibilidad y compatibilidad con tecnología asistiva—básicamente un pequeño milagro que llega a miles de millones de dispositivos.
Cuando los equipos los “personalizan”, el error más común es reemplazar ese milagro con un div y buenas intenciones. A veces incluso pasa una auditoría superficial: parece clicable y cambia con el ratón. Pero falla en el momento en que intentas tabular, usar modo de alto contraste, hacer zoom al 200% o ejecutar un lector de pantalla. En términos de operaciones: pasa en staging donde todo el mundo usa el mismo MacBook; se desmorona en producción donde la flota es la realidad.
Definición: un control personalizado que no miente
Una casilla/radio personalizado “no miente” si cumple estas restricciones:
- El elemento nativo sigue siendo la fuente de verdad. Usa
<input type="checkbox">/<input type="radio">. No recrees su comportamiento en JS. - El etiquetado es real. Usa
<label for="…">o envuelve el input en un label para que los objetivos de clic/tap funcionen sin hacks. - El teclado funciona por defecto. Tab enfoca el input; Space alterna la casilla; las flechas navegan dentro de grupos de radio (con las reglas del navegador).
- El foco es visible. Especialmente con
:focus-visible. No “hacemos una sombra sutil” que desaparece a la luz del sol. - Los estados están representados. Marcado/no marcado, deshabilitado, inválido y (para casillas) indeterminado.
- El alto contraste y los colores forzados no lo rompen. Si dibujas todo con imágenes de fondo, forced-colors convertirá tu control en un fantasma.
Opinión: Si no puedes mantener el input nativo en el DOM, no construyas casillas personalizadas. Elige un diseño visual distinto que funcione con la plataforma. Tus directrices de marca no pagarán tu demanda por ADA.
Una cita que deberías pegar en tu monitor
“La esperanza no es una estrategia.” — Gordon R. Dickson
No es una cita web, pero es una verdad de ingeniería: esperar que tu UI personalizada se comporte como una casilla es cómo envías outages en forma humana.
Hechos y contexto histórico que vale la pena conocer
Algo de contexto hace que las restricciones de hoy no parezcan arbitrarias. Aquí hay hechos concretos que cambian cómo diseñas:
- Los primeros formularios web replicaban formularios en papel. Las casillas y radios estaban pensados para ser predecibles, no marcas. Esa base “aburrida” es por qué son tan interoperables.
- CSS no pudo estilizar controles nativos de forma fiable durante años. Los navegadores trataban los inputs como widgets del SO con ganchos limitados; los “controles personalizados” se convirtieron en una industria de hacks.
- La asociación label-input es más antigua que la mayoría de los frameworks. El patrón
for/ides una affordance de usabilidad fundamental, no un añadido de accesibilidad. - Los grupos de radio tienen semántica de teclado integrada. La navegación con flechas y la selección mutuamente exclusiva la maneja el navegador cuando el
namecoincide. :focus-visibleexiste porque los anillos de foco estaban siendo maltratados. Los diseñadores quitaban los outlines; los usuarios perdían la pista de dónde estaban. Los navegadores respondieron con una heurística más inteligente.- El modo forced-colors no es marginal. Windows High Contrast (y forced colors modernos) lo usan personas que no pueden leer UI de bajo contraste—no usuarios que disfrutan tus degradados.
- Los iconos SVG no son semántica de accesibilidad. Dibujar una marca de verificación no informa al árbol de accesibilidad. El input sí.
- El estado indeterminado es real. Es un estado de UI, no un valor; no se enviará como “tal vez”. Se usa a menudo para “Seleccionar todo” con selecciones parciales.
- Los navegadores difieren en tamaño y alineación por defecto. Si te apoyas en métricas por defecto, tu comp pixel-perfect derivará entre plataformas. Si reemplazas la semántica, tu comportamiento derivará. Elige tu deriva.
Nada de eso es trivia. Cada punto es una razón por la que el patrón de “checkbox en div” sigue rompiéndose con usuarios reales.
Patrones que funcionan con CSS puro (y por qué)
Patrón A: Ocultar visualmente el input nativo, estilizar un sibling
Este es el caballo de batalla. Mantienes el input real en el DOM, focalizable e interactivo, pero oculto visualmente. Luego estilizas un span (u otro) como el “cuadro/círculo” usando selectores input:checked + .control.
Por qué funciona: el navegador sigue siendo el dueño del comportamiento, las tecnologías asistivas siguen viendo una casilla/radio, los formularios siguen enviando correctamente y puedes tematizar con CSS.
Por qué falla: la gente oculta el input con display:none o visibility:hidden (lo saca del foco/AT). O lo superponen pero rompen pointer events. O se olvidan del estilo de foco.
Patrón B: Estilar el input con appearance (con cuidado)
El CSS moderno te da appearance: none en muchos navegadores, permitiéndote restilar el input nativo directamente. Esto puede ser limpio. También puede explotar con forced-colors, particularidades de plataforma y navegadores antiguos.
Mi opinión: úsalo solo si tu matriz de soporte es moderna y pruebas explícitamente forced colors y zoom. Si no, el Patrón A es más robusto.
Patrón C: Usar accent-color cuando solo necesitas que estén con el color de marca
Si tu objetivo es “hacer las casillas azules”, no “inventar una nueva casilla”, usa accent-color. Mantiene la renderización nativa pero cambia el color de realce. Es la opción de menor riesgo y la menos emocionante—por eso es perfecta.
Regla: Cuanto más “personalizada” parezca tu casilla, más pruebas operacionales necesita. Trata los controles personalizados como una dependencia de producción, no como un adorno CSS.
Qué evitar: role=checkbox en un div
Sí, ARIA tiene role="checkbox". No, no te da paridad gratis con inputs nativos. Te estás comprometiendo a implementar teclado, foco, asociación de etiquetas, integración con formularios, estados deshabilitados y matices de lectores de pantalla. También te comprometes a equivocarte en al menos una combinación navegador/AT que no controlas.
Si debes hacerlo (app embebida, sin formularios, restricciones extremas), escríbelo como un componente con un SLO, pruebas en AT y un plan de rollback. Si no: no lo hagas.
Recetas CSS: checkbox, radio y “toggle” sin engañar
HTML base que escala
Esta estructura es aburrida. Ese es el punto. Cada opción es un label envolviendo input y elementos visuales. Crea un objetivo de clic grande y mantiene la asociación a prueba de balas sin depender de IDs.
cr0x@server:~$ cat controls.html
<fieldset class="choices">
<legend>Notification settings</legend>
<label class="choice">
<input class="choice__input" type="checkbox" name="email_alerts">
<span class="choice__control" aria-hidden="true"></span>
<span class="choice__text">Email alerts</span>
</label>
<label class="choice">
<input class="choice__input" type="checkbox" name="sms_alerts" disabled>
<span class="choice__control" aria-hidden="true"></span>
<span class="choice__text">SMS alerts (disabled)</span>
</label>
</fieldset>
<fieldset class="choices">
<legend>Pager escalation</legend>
<label class="choice">
<input class="choice__input" type="radio" name="pager" value="none">
<span class="choice__control choice__control--radio" aria-hidden="true"></span>
<span class="choice__text">None</span>
</label>
<label class="choice">
<input class="choice__input" type="radio" name="pager" value="critical">
<span class="choice__control choice__control--radio" aria-hidden="true"></span>
<span class="choice__text">Critical only</span>
</label>
</fieldset>
Fíjate en el aria-hidden="true" en el span decorativo control. El input ya proporciona la semántica; no queremos que el adorno aparezca en el árbol de accesibilidad.
CSS: oculto visualmente, no muerto funcionalmente
Aquí está la parte crucial: “oculto visualmente” significa aún focalizable y aún en el árbol de accesibilidad. No uses display:none. No uses visibility:hidden. Eso equivale a desenchufar un disco porque el LED molesta.
cr0x@server:~$ cat controls.css
.choices {
border: 1px solid #d0d7de;
border-radius: 10px;
padding: 12px 14px;
margin: 14px 0;
}
.choice {
display: grid;
grid-template-columns: 1.4rem 1fr;
align-items: start;
gap: 0.6rem;
padding: 0.45rem 0;
cursor: pointer;
}
.choice__input {
/* Visually hidden but still focusable */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.choice__control {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #5b6472;
border-radius: 0.35rem;
display: inline-grid;
place-items: center;
background: #fff;
box-sizing: border-box;
}
.choice__control--radio {
border-radius: 999px;
}
.choice__text {
color: #111;
}
/* Checked */
.choice__input:checked + .choice__control {
border-color: #0b5fff;
background: #0b5fff;
}
.choice__input:checked + .choice__control::after {
content: "";
width: 0.65rem;
height: 0.65rem;
background: #fff;
border-radius: 0.18rem;
}
.choice__input:checked + .choice__control--radio::after {
border-radius: 999px;
width: 0.55rem;
height: 0.55rem;
}
/* Focus */
.choice__input:focus-visible + .choice__control {
outline: 3px solid #0b5fff;
outline-offset: 2px;
}
/* Disabled */
.choice__input:disabled + .choice__control {
border-color: #9aa4b2;
background: #f1f3f5;
}
.choice__input:disabled ~ .choice__text {
color: #667085;
}
.choice:has(.choice__input:disabled) {
cursor: not-allowed;
}
/* Forced colors */
@media (forced-colors: active) {
.choice__control {
forced-color-adjust: none;
border-color: CanvasText;
background: Canvas;
}
.choice__input:checked + .choice__control {
background: Highlight;
border-color: Highlight;
}
.choice__input:checked + .choice__control::after {
background: HighlightText;
}
.choice__input:focus-visible + .choice__control {
outline-color: Highlight;
}
}
Importante: :has() se usa arriba para el estilo del cursor. Si necesitas soportar navegadores sin él, elimina esa regla y acepta un cursor menos perfecto. No lo reemplaces con JS. La corrección del cursor no vale el riesgo semántico.
Casillas indeterminadas: el estado que todos olvidan
Indeterminate no es un valor que el usuario pueda alternar directamente con una casilla nativa; normalmente lo establece la aplicación cuando los hijos están parcialmente seleccionados. Aún puedes estilizarlo con CSS si usas la pseudo-clase :indeterminate.
cr0x@server:~$ cat indeterminate.css
.choice__input:indeterminate + .choice__control {
border-color: #0b5fff;
background: #0b5fff;
}
.choice__input:indeterminate + .choice__control::after {
content: "";
width: 0.7rem;
height: 0.15rem;
background: #fff;
border-radius: 999px;
}
Establecer indeterminate suele requerir JS (porque HTML no te permite declararlo). Pero puedes mantener el comportamiento nativo: JS establece input.indeterminate = true; CSS lo estiliza. Eso es honesto.
Una palabra sobre los switches tipo toggle
Todos quieren un switch estilo iOS. Pero la mayoría de los componentes “switch” son solo una casilla con un disfraz. Puede estar bien si no mientes sobre lo que es: usa una casilla, etiquétala claramente y no la fuerces a un rol ARIA raro salvo que tengas una buena razón. El input sigue siendo el control; el switch es decoración.
Broma #1 Un “toggle personalizado” construido sobre un div es como un RAID 0 de sentimientos: rápido de enviar, catastrófico de confiar.
Todos los estados que debes soportar (y cómo fallan)
Marcado vs no marcado
Esta es la parte fácil visualmente, y la más fácil de invertir accidentalmente. He visto CSS que renderiza la marca cuando no está marcada porque alguien intercambió selectores durante un refactor. Si tus visuales y tu valor real divergen, has creado una UI que miente.
Foco y navegación por teclado
Los usuarios de teclado no son un nicho. Incluyen power users, personas con discapacidades motoras, equipos con kioscos y desarrolladores que simplemente prefieren Tab porque es más rápido. Las cosas críticas:
- Tab debe alcanzar el control en un orden predecible.
- El foco debe ser visible cuando se alcanza.
- Space alterna casillas y activa radios.
- Dentro de un grupo de radios, las flechas navegan opciones (detalles dependientes del navegador), pero el comportamiento del foco debe seguir siendo sensato.
Si ocultas mal el input, el foco desaparece. Si falseas el input con un div, probablemente olvidarás Space o las flechas. Si quitas los outlines, el foco se vuelve una búsqueda del tesoro.
Deshabilitado
Los controles deshabilitados necesitan tanto deshabilitado semántico (atributo disabled) como deshabilitado visual (colores/cursor). No solo lo pongas en gris. Eso es solo hacer que algo parezca deshabilitado mientras aún se puede alternar, que es el equivalente UI de un sistema de ficheros de solo lectura que acepta escrituras hasta que falla.
Inválido y mensajes de error
Los grupos de casillas a menudo fallan validación (“Debes aceptar los términos”). El control debería soportar :invalid y/o una clase explícita de error. El mensaje de error debe estar asociado programáticamente (típicamente con aria-describedby en el input o en el grupo). El CSS puro puede manejar lo visual; las semánticas requieren disciplina en HTML.
Alto contraste y colores forzados
Si tu marca de verificación es una imagen de fondo, forced-colors probablemente la ignorará. Por eso la receta usa bordes y fondos, además de forced-color-adjust y colores del sistema como CanvasText. El objetivo no es preservar tu paleta exacta; es preservar el significado.
Zoom, texto grande y objetivos táctiles
Al 200% de zoom, tu casilla de 12px se vuelve un instrumento de precisión. Usa un wrapper label y padding generoso para que el objetivo de tap/clic sea grande. En apps corporativas, un porcentaje sorprendente de uso es en portátiles táctiles. Los controles pequeños se convierten en reportes de “¿por qué está roto?”.
Tres mini-historias corporativas desde el campo
Incidente: la suposición equivocada que convirtió el consentimiento en caos
Un equipo de producto desplegó una pantalla de consentimiento rediseñada: categorías de cookies, opt-ins de marketing, el buffet de cumplimiento habitual. El nuevo diseño usó casillas personalizadas implementadas como divs con manejadores de click. Alguien añadió role="checkbox" y aria-checked y asumió que eso lo hacía equivalente.
Funcionó mayormente con el ratón. Funcionó en el navegador preferido del diseñador. El incidente empezó en silencio: soporte recibió informes dispersos de “No puedo optar por salir” o “la casilla sigue revirtiéndose”. Los informes eran inconsistentes. Eso es lo más peligroso.
Entonces legal se involucró. Un usuario grabó una sesión: usando navegación por teclado, Tab saltó algunos controles y Space desplazó la página en vez de alternar. La UI mostraba visualmente “no marcado”, pero el estado subyacente era “marcado” porque el manejador de click se disparó durante clics en labels de formas extrañas. Caminos distintos, estado distinto. Una UI de consentimiento que no refleja de forma fiable el valor real no es un bug de diseño; es un riesgo operativo con consecuencias regulatorias.
La corrección fue poco glamorosa: quitar los controles en div, reintroducir inputs nativos y estilo con spans hermanos. El equipo también añadió una prueba de humo de accesibilidad en CI. No porque fueran santos, sino porque no querían otra guerra cross-funcional por una casilla.
Optimización que salió mal: recortar nodos DOM, romper comportamiento
Otra empresa tenía una herramienta interna con muchos formularios. Las quejas de rendimiento eran reales: portátiles viejos, tablas grandes, muchos controles. Alguien propuso una “optimización”: eliminar marcado extra de controles personalizados estilando inputs directamente con appearance:none y quitando labels envolventes. Menos DOM, render más rápido—sobre el papel.
El resultado: una mejora medible en tiempo de render inicial en un benchmark. Y luego el retroceso. Los objetivos de clic se encogieron porque los labels ya no envolvían el texto. Los usuarios empezaron a fallar controles; aumentaron las tasas de error. El canal de soporte se llenó de “no se guardó” que en realidad eran “no hice clic en la casilla pequeña”.
Peor: los anillos de foco eran inconsistentes entre navegadores al estilizar el input directamente. Algunas combinaciones de CSS provocaban que el indicador de foco quedara recortado por reglas overflow en layouts de contenedor. Los usuarios de teclado quedaban efectivamente ciegos. La ganancia de rendimiento se comió con pérdida de productividad e interrupciones de “está roto”.
La lección no fue “nunca optimices DOM”. Fue “optimiza donde importa”. Mantuvieron un patrón de marcado ligeramente más grande (input + span control + span texto), y optimizaron el render en otros lugares: virtualización, menos reflows y un contención de CSS sensata. La casilla no era el cuello de botella. Solo era el chivo expiatorio.
Aburrido pero correcto: la práctica que salvó un outage
Un equipo cercano a pagos mantenía un formulario de onboarding en varios pasos. Tenían una política: cualquier control de formulario personalizado debe tener una prueba de aceptación solo con teclado documentada y ejecutada antes de cada release. Sin excepciones. No era popular en épocas de crunch, y no era glamoroso.
Un viernes, un diseñador envió un “pequeño” cambio visual: ocultar el checkbox nativo con display:none porque “seguía mostrando un píxel”. El resto del control seguía luciendo bien. Los clics de ratón seguían funcionando porque el manejador de click en el label alternaba cierto estado en JS (sí, había JS también). Habría llegado a producción.
La prueba de aceptación lo detectó en minutos: Tab ya no enfocaba la casilla. La salida del lector de pantalla cambió. El equipo revirtió el CSS y usó el patrón correcto de ocultado visual. Producción nunca vio la regresión. Nadie fue paginado por un cambio UI, que es el mejor tipo de incidente: el que no tienes.
Esa política sonaba a burocracia hasta que previno una falla de alto impacto en un funnel crítico. Las prácticas aburridas suelen ser simplemente fiabilidad con una carpeta.
Guía rápida de diagnóstico
Cuando las casillas/radios personalizados “se sienten rotos”, no empieces por ajustar colores. Empieza por probar semántica y comportamiento. Aquí hay una secuencia rápida, apta para producción, que encuentra el cuello de botella con rapidez.
Primero: prueba que el input nativo existe y es focalizable
- ¿Puedes hacer Tab hasta él?
- ¿Space lo alterna?
- ¿Aparece el anillo de foco en algún lugar visible?
Si no: tu input está oculto incorrectamente (display:none, visibility:hidden), fuera de pantalla sin estilo de foco, o cubierto por otro elemento.
Segundo: prueba la asociación de label y el objetivo de clic
- Haz clic en el texto, no en la casilla. ¿Se alterna?
- ¿Funciona tocar en móvil de forma fiable?
Si no: el label no está asociado, o eventos pointer están siendo interceptados por tu elemento decorativo.
Tercero: prueba la paridad de estado (visual vs real)
- Inspecciona el estado
checkeddel input en devtools mientras alternas. - Envía el formulario e inspecciona la carga enviada.
Si la UI muestra marcado pero el input no lo está (o viceversa), tienes un “control mentiroso”. Para. Arregla la fuente de verdad: el input debe poseer el estado.
Cuarto: colores forzados y zoom
- Prueba forced colors (Windows) o emula forced colors donde sea posible.
- Haz zoom al 200% y asegúrate de que el área de interacción aún funcione.
Si falla aquí: dependes de visuales no adaptativos (imágenes de fondo, degradados, outlines finos) o objetivos diminutos.
Quinto: comportamiento de grupos de radio
- Confirma que todos los radios comparten un
name. - Navega con las flechas por las opciones y observa las reglas de selección.
Si falla esto: los inputs no son realmente radios, o tu estructura DOM interfiere con foco/interacción.
Broma #2 Depurar radios personalizados es como depurar DNS: nunca es el radio, hasta que sí.
Tareas prácticas: comandos, salidas, decisiones
Estas son tareas reales que puedes ejecutar en una workstation o runner de CI para diagnosticar “controles que mienten”. Cada tarea incluye un comando, salida de ejemplo, qué significa y qué decidir después. El objetivo es operativo: reducir la ambigüedad con rapidez.
Tarea 1: Confirmar que los inputs existen y no tienen display:none
cr0x@server:~$ rg -n 'display\s*:\s*none|visibility\s*:\s*hidden' src/ styles/
src/components/Choice.css:41: display: none;
La salida significa: Una hoja de estilos usa display:none en algo—a menudo el input.
Decisión: Reemplazar con un patrón visualmente oculto que preserve foco/AT. Si la regla apunta al input, trátalo como un bug Sev-2 en tu librería UI.
Tarea 2: Encontrar implementaciones “checkbox” basadas en div
cr0x@server:~$ rg -n 'role="checkbox"|role="radio"|aria-checked' src/
src/components/LegacyToggle.tsx:17: return <div role="checkbox" aria-checked={checked} ...>
La salida significa: Alguien está implementando semántica de checkbox manualmente.
Decisión: Auditar manejo de teclado y etiquetado. Si esto es un control de formulario, programar su reemplazo por inputs nativos a menos que exista una restricción fuerte.
Tarea 3: Verificar agrupación de radios por name
cr0x@server:~$ rg -n 'type="radio"' src/ | head
src/pages/Preferences.html:88: <input type="radio" name="pager" value="none">
src/pages/Preferences.html:94: <input type="radio" name="pager" value="critical">
La salida significa: Puedes ver si el name es consistente en el grupo.
Decisión: Si los names difieren, arréglalos. Si coinciden pero el comportamiento es extraño, revisa si hay JS interceptando eventos de tecla o elementos interactivos anidados.
Tarea 4: Comprobar inputs sin labels
cr0x@server:~$ rg -n '<input[^>]+type="checkbox"|<input[^>]+type="radio"' src/ | head -n 20
src/pages/Checkout.html:211: <input type="checkbox" id="tos">
La salida significa: Los inputs existen; ahora debes asegurar la asociación de label.
Decisión: Confirmar que exista un <label for="tos"> correspondiente o que el input esté envuelto por un label. Si no, añadirlo. No “arregles” con manejadores de click en JS.
Tarea 5: Detectar trampas de pointer-events en elementos decorativos
cr0x@server:~$ rg -n 'pointer-events\s*:\s*auto|pointer-events\s*:\s*none' src/styles/
src/styles/controls.css:77: .choice__control { pointer-events: auto; }
La salida significa: Elementos decorativos podrían estar interceptando clicks/taps.
Decisión: Normalmente establecer pointer-events:none en el control decorativo y dejar que el label maneje la interacción, a menos que tengas una razón específica. Luego volver a probar el tap en móvil.
Tarea 6: Ejecutar checks de accesibilidad axe-core en CI (headless)
cr0x@server:~$ npx playwright test --project=chromium --grep "@a11y"
Running 6 tests using 1 worker
✓ 6 passed (18.2s)
La salida significa: Tus tests automatizados de a11y pasaron (por lo que cubren).
Decisión: Manténlos, pero no te quedes ahí. Añade tests scriptados solo con teclado para orden de foco y alternancia; axe no detectará todo sobre la “sensación” o colores forzados.
Tarea 7: Validar contraste de color para anillos de foco y estados
cr0x@server:~$ node -e 'console.log("Manual check: verify focus ring color against backgrounds in design tokens")'
Manual check: verify focus ring color against backgrounds in design tokens
La salida significa: Esto es un recordatorio: el contraste es en parte medible, en parte contextual.
Decisión: Asegurar que el color del anillo de foco cumpla expectativas de contraste frente a fondos claros/oscuros. Si tu sistema tiene theming, prueba ambos temas.
Tarea 8: Detectar uso de imágenes de fondo para marcas de verificación
cr0x@server:~$ rg -n 'background-image|mask-image|data:image' src/styles/
src/styles/checkbox.css:19: background-image: url("check.svg");
La salida significa: Las marcas se dibujan vía imágenes/masks.
Decisión: Si soportas forced colors, reemplaza por formas CSS (bordes/fondo) o asegúrate de que overrides para forced-colors proporcionen estados visibles.
Tarea 9: Comprobar eliminación global de outline
cr0x@server:~$ rg -n 'outline\s*:\s*none|outline\s*:\s*0' src/styles/
src/styles/reset.css:12: *:focus { outline: none; }
La salida significa: Un reset global está matando indicadores de foco en todo el sitio.
Decisión: Elimínalo, o reemplázalo por estilos con :focus-visible. Trátalo como un bug de fiabilidad; rompe la navegación bajo estrés (y en auditorías).
Tarea 10: Verificar que los formularios envían los valores esperados
cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlencode
payload = {"email_alerts": "on", "pager": "critical"}
print(urlencode(payload))
PY
email_alerts=on&pager=critical
La salida significa: Una casilla marcada envía su nombre con valor “on” por defecto; los radios envían el value seleccionado.
Decisión: Si tu backend espera valores distintos, define un value explícito en las casillas o transforma en el servidor. No inventes estado cliente separado del input.
Tarea 11: Verificar que indeterminate no se confunda con checked
cr0x@server:~$ node - <<'NODE'
console.log("Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.")
NODE
Indeterminate is a UI state, not a submitted value. Checked controls submission; indeterminate does not.
La salida significa: Un recordatorio de un bug lógico común: tratar indeterminate como “true”.
Decisión: Asegura que tu lógica establezca tanto checked como indeterminate explícitamente según las selecciones hijas, y que la lógica de envío refleje solo checked.
Tarea 12: Prueba de humo del orden de foco ejecutando un pase de teclado scriptado
cr0x@server:~$ npx playwright test -g "keyboard navigation"
Running 1 test using 1 worker
✓ 1 passed (4.9s)
La salida significa: Tu test de navegación por teclado pasó. (Si no tienes uno, esto debería fallar porque no existe. Ese es el punto.)
Decisión: Añade aserciones que Tab alcance el input, que Space lo alterne y que el anillo de foco esté presente. Trátalo como test de regresión para una API crítica.
Tarea 13: Comprobar estilos computados para overrides de forced-colors (runner Windows)
cr0x@server:~$ node -e 'console.log("Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.")'
Decision point: run a Windows job to validate forced-colors snapshots; Linux/macOS runners won’t represent it.
La salida significa: Forced colors está ligado a la plataforma; necesitas el entorno adecuado.
Decisión: Si la accesibilidad está en scope (lo está), añade al menos una lane de CI en Windows o un paso de prueba manual en Windows para releases que incluyan controles.
Tarea 14: Detectar uso accidental de tabindex en spans decorativos
cr0x@server:~$ rg -n 'tabindex=' src/components/
src/components/Choice.tsx:23: <span class="choice__control" tabindex="0"></span>
La salida significa: Elementos decorativos se están haciendo focalizables, lo que puede romper el orden Tab y confundir lectores de pantalla.
Decisión: Elimina tabindex de elementos no interactivos. Mantén el foco en el input nativo. Si necesitas un área de foco más grande, usa estilos en el label y padding.
Errores comunes: síntomas → causa raíz → solución
| Síntoma | Causa raíz | Solución |
|---|---|---|
| Tab salta la casilla/radio por completo | Input oculto con display:none o eliminado del DOM; o tabindex mal usado |
Usar un patrón visualmente oculto (clip/1px) y asegurar que solo el input sea focalizable |
| Space no alterna; la página se desplaza en su lugar | No es un input real; uso de div con manejadores click; falta manejo keydown | Usar inputs nativos. Si debes usar roles ARIA, implementa soporte completo de teclado (y acepta mantenimiento continuo) |
| Hacer clic en el texto de la etiqueta no alterna | Sin asociación de label (falta for/id, o input no envuelto) |
Envuelve el input en un label o conecta for correctamente; elimina hacks JS de click |
| El anillo de foco existe pero es invisible | Outline removido en reset; color de foco con contraste bajo; recortado por overflow | Usar estilos con :focus-visible y contraste suficiente; evitar contenedores que recorten o añadir outline-offset/espacio |
| Visual marcado no coincide con el valor enviado | Estado visual gestionado separadamente (toggle de clases) del checked del input |
Hacer del input la única fuente de verdad; estilizar vía selectores :checked únicamente |
| Modo de alto contraste muestra casillas en blanco | Marcas dibujadas con imágenes/masks; colores sobrescritos por forced colors | Usar fondos/bordes CSS y añadir @media (forced-colors: active) overrides con colores del sistema |
| Grupo de radio permite múltiples selecciones | Inputs no comparten el mismo name; o no son radios reales |
Asegurar name consistente en el grupo; mantener inputs de radio nativos |
| Usuarios táctiles se quejan “difícil de pulsar” | Área de objetivo pequeña; solo la casilla es clicable y el texto no; padding demasiado ajustado | Envuelve con label; añade padding y espaciado; considera guía de objetivo táctil mínimo de 44px |
| El lector de pantalla anuncia “grupo” pero no las opciones claramente | Falta fieldset/legend para controles agrupados; o etiquetado incorrecto |
Usar <fieldset> y <legend> para grupos; asegurar que cada input tenga una etiqueta |
| Deshabilitado parece deshabilitado pero aún se alterna | Solo estilado como deshabilitado; falta atributo disabled real |
Poner disabled en el input; estilizar estados con :disabled; quitar toggles en JS |
Listas de verificación / plan paso a paso
Paso a paso: construir un componente de casilla/radio confiable (solo CSS)
- Empieza con HTML nativo. Usa input + label. Para grupos, usa fieldset + legend.
- Decide el nivel de personalización. Si
accent-colorlo soluciona, detente allí. - Elige Patrón A o B. Prefiere Patrón A (input oculto + sibling estilizado) por robustez.
- Implementa el input visualmente oculto correctamente. Usa la técnica de clip/1px; nunca
display:none. - Estiliza estados desde selectores. Usa
:checked,:disabled,:focus-visible,:indeterminate. - Haz el foco inconfundible. Usa un outline visible con offset. No confíes en sombras sutiles.
- Soporta colores forzados. Añade overrides
@media (forced-colors: active)y usa colores del sistema. - Verifica tamaño del objetivo de clic. Wrapper label, padding y espaciado deben hacer la selección fácil al 200% de zoom.
- Prueba solo con teclado. Tab, Shift+Tab, Space; navegación con flechas en radios.
- Prueba al menos una ruta con lector de pantalla. Incluso una prueba de humo básica detecta problemas obvios de etiquetado.
- Añade tests de regresión. Axe más un test scriptado de navegación por teclado.
- Despliega con plan de rollback. Si es un cambio en el sistema de diseño, trátalo como una actualización de librería compartida.
Checklist de release (lo que exigiría en una organización de producción)
- Anillo de foco visible en temas claro y oscuro
- Pase de teclado: cada control alcanzable; Space alterna; radios se comportan en grupo
- Pase de mouse y táctil: el texto de label alterna; sin objetivos de clic diminutos
- Pase forced-colors (Windows): estados marcado y foco siguen distinguibles
- Pase de envío de formulario: la payload coincide con el estado visual; controles deshabilitados no se envían
- Pase de error/inválido: el mensaje de error está asociado y visible
- Pase indeterminate (si se usa): estilo y lógica de estado confirmados
- No hay
outline:noneglobal en el CSS enviado
Marco operacional: los controles personalizados son infraestructura compartida. Si fallan, todo lo downstream falla: onboarding, checkout, settings, consentimiento. Trátalos como un servicio central.
Preguntas frecuentes
1) ¿Puedo ocultar el input con opacity: 0 en lugar de la técnica clip?
A veces. Pero los inputs con opacity:0 siguen ocupando layout y pueden crear áreas de clic extrañas. El patrón de input visualmente oculto clip/1px es más predecible y ampliamente usado para accesibilidad.
2) ¿Es display:none aceptable alguna vez para el input?
No si ese input es el control interactivo. display:none lo saca del árbol de accesibilidad y de la navegación por teclado. Si el input es puramente redundante (raro), quizá—pero entonces no deberías tenerlo.
3) ¿Debo usar role="switch" para toggles?
Solo si realmente necesitas semántica de switch y sabes lo que haces. Para muchos productos, una casilla etiquetada “Habilitar X” es más clara y compatible. Un role switch aumenta la carga de pruebas entre AT.
4) ¿Son seguras las pseudo-elementos para marcas de verificación?
Sí, si son puramente decorativos y dirigidos por el estado del input (:checked + span::after). En forced colors, puede que necesites overrides para que el pseudo-elemento siga siendo visible.
5) ¿Qué hay de usar SVG para la marca?
SVG está bien como decoración. No uses SVG para reemplazar el control semántico. También verifica el comportamiento en forced colors; algunos rellenos SVG no se adaptan a menos que los manejes.
6) ¿Necesito atributos ARIA en inputs nativos?
Normalmente no. Los inputs nativos ya exponen marcado/no marcado/deshabilitado. Usa ARIA para describir errores (aria-describedby) o para agrupado si no puedes usar fieldset/legend. Evita ARIA redundante que pueda confundir AT.
7) ¿Por qué se recomienda :focus-visible en lugar de :focus?
:focus-visible generalmente muestra estilos de foco para teclado y otras interacciones no pointer sin mostrar anillos al hacer clic con el ratón. Es un mejor compromiso por defecto. Aún puedes recurrir a :focus si hace falta.
8) ¿Cómo manejo la validación de “checkbox requerido” accesiblemente?
Marca la casilla como required (required) o valida a nivel de grupo. Proporciona un mensaje de error adyacente al control y enlázalo con aria-describedby. Visualmente, estiliza :invalid o una clase de error en el wrapper.
9) ¿Cuál es la matriz mínima de pruebas para controles personalizados?
Como mínimo: un navegador basado en Chromium, uno Firefox, uno Safari (si lo soportas), pase solo con teclado, una ruta con lector de pantalla y una comprobación de forced-colors en Windows si la accesibilidad está en scope (lo está).
10) Si uso accent-color, ¿sigo necesitando todo esto?
Necesitas menos complejidad CSS, pero aún necesitas etiquetado, agrupado y saneidad de foco. accent-color reduce la superficie para forced-colors y desacoples de estado, por eso suele ser el mejor primer paso.
Próximos pasos que puedes hacer esta semana
Si gestionas sistemas en producción, ya entiendes este patrón: las fallas más peligrosas vienen de interfaces que parecen correctas mientras hacen lo incorrecto. Las casillas y radios personalizados son exactamente ese tipo de riesgo cuando se construyen sin cuidado.
Pasos prácticos:
- Haz inventario de tus controles. Grepea por
role="checkbox", resets de outline y inputs ocultos. - Estandariza un patrón honesto. Prefiere inputs nativos + wrapper label + estilizado en sibling. Escríbelo una vez, reutilízalo en todos lados.
- Añade un test de navegación por teclado. Haz que falle ruidosamente en regresiones.
- Ejecuta una comprobación de forced-colors antes de desplegar rediseños visuales. Si no puedes automatizarlo, hazlo un paso de release.
- Documenta los modos de fallo. Pon “no usar display:none en inputs” en las reglas del sistema de diseño, junto a uso de tokens.
Haz eso y tus controles personalizados dejarán de mentir. También dejarán de generar el tipo de caos de bajo grado y alto coste que arruina sprints y carga silenciosamente a tu equipo de soporte. Aburrido es bueno. Aburrido es fiable.