shadcn/ui y Radix: cómo mantener la accesibilidad al personalizar componentes

La semana pasada un compañero me preguntó: «¿Por qué no puedo pulsar este botón con el teclado?»
Me quedé un segundo en blanco. Estábamos usando shadcn/ui, ¿cómo podía pasar eso? Abrí DevTools y vi que había envuelto Tooltip.Trigger en un <div> para añadir un estilo personalizado. El problema estaba ahí.
Para ser honesto, yo también he caído en trampas similares. Al empezar con shadcn/ui pensaba que esos componentes «se podían tocar a gusto», porque el código se copia directamente al proyecto. Cambiar estilos, cambiar etiquetas, añadir un wrapper… todo parecía inofensivo. Hasta que QA detectó que la navegación por teclado fallaba, el lector de pantalla no leía el contenido y todo el flujo se rompió.
Ahí entendí que la «libertad» de shadcn/ui tiene un precio. Te da el código fuente, pero debajo hay magia de accesibilidad de Radix. Si la alteras mal, esa capa se rompe.
Este artículo habla de la relación entre shadcn/ui y Radix, con foco en cómo mantener la accesibilidad al personalizar componentes. Al terminar deberías entender cómo usar asChild, gestionar el foco y heredar ARIA — al menos saber qué puedes tocar y qué no.
shadcn/ui y Radix: ¿cuál es la relación?
Primero, algo que mucha gente no tiene claro: shadcn/ui no es un paquete npm.
No puedes hacer npm install @shadcn/ui. Es esencialmente una «plataforma de distribución de código»: te da el código fuente del componente, lo copias al proyecto y pasa a ser tuyo. Puedes modificarlo, borrarlo, nadie te lo impide.
¿De dónde viene entonces la accesibilidad? De Radix.
Radix UI es una biblioteca de componentes «sin estilos», también llamada Primitives. No te da apariencia, te da comportamiento: cómo Dialog gestiona el foco al abrirse, cómo Dropdown Menu maneja las flechas arriba/abajo, cómo Tooltip se oculta del lector de pantalla. Todo cumple WAI-ARIA y está probado con NVDA, JAWS y VoiceOver.
shadcn/ui es Radix con una capa de estilos Tailwind CSS encima. Te da buena apariencia y esconde el comportamiento accesible de Radix debajo. Copias el código del botón, ves unas clases Tailwind, pero dentro va la lógica de Radix.
Dicho de forma directa:
- Radix se encarga de que «funcione»: atributos aria, role, gestión del foco, navegación por teclado
- shadcn/ui se encarga de que «se vea bien»: estilos Tailwind, coherencia de diseño
Así que al modificar componentes de shadcn/ui recuerda: cambias la «capa superficial», pero la lógica de comportamiento viene de Radix. La superficie se puede tocar; la base, si la rompes, hay problemas.
La propiedad asChild: ¿magia o trampa?
asChild es una propiedad muy particular de Radix. La mayoría de las partes de los componentes Radix la soportan.
¿Qué significa? Por ejemplo, Tooltip.Trigger renderiza por defecto un <button>. Pero quizá quieras añadir un tooltip a un enlace; ahí entra asChild:
<Tooltip.Trigger asChild>
<a href="/help">Centro de ayuda</a>
</Tooltip.Trigger>
Con asChild={true}, Radix ya no renderiza su propio <button>, sino que «clona» el elemento hijo que proporcionas y le pasa su comportamiento y atributos. Ese enlace tiene toda la funcionalidad de Tooltip Trigger: hover muestra el tooltip, el foco por teclado también lo activa y los atributos aria son correctos.
Parece muy cómodo.
Pero la trampa está ahí.
Si lo cambias por un elemento no enfocable, se pierde toda la accesibilidad.
// ❌ Ejemplo incorrecto
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">Haz clic aquí</div>
</Tooltip.Trigger>
Un div no recibe foco por teclado (salvo que añadas tabIndex={0} manualmente) ni responde a Enter/Espacio. El lector de pantalla no lo tratará como botón. Con teclado, el usuario no «llega» a ese tooltip.
La documentación oficial de Radix lo deja claro: «If you were to switch it to a div, it would no longer be accessible.»
Dicho esto, normalmente no cambias directamente a div. Lo más habitual es usar tu propio componente React:
<Tooltip.Trigger asChild>
<MyButton>Haz clic aquí</MyButton>
</Tooltip.Trigger>
Eso está bien, pero hay dos reglas obligatorias:
1. Tu componente debe hacer spread de props
Al clonar el hijo, Radix pasa un montón de props: manejadores de eventos, atributos aria, ref. Si tu componente no las recibe, la funcionalidad se corta.
// ❌ Incorrecto: no acepta props
const MyButton = () => <button className="btn">...</button>
// ✅ Correcto: spread de todas las props
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. Tu componente debe hacer forward ref
Radix a veces necesita acceso directo al DOM (medir tamaño, gestionar foco). Sin ref, dará error.
// ❌ Incorrecto: no acepta ref
const MyButton = (props) => <button {...props}>...</button>
// ✅ Correcto: forward ref
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
Estas dos reglas no son solo de Radix: en cualquier «componente hoja» deberías hacer lo mismo. Aceptar props y ref es lo mínimo.
También puedes anidar varios componentes Radix:
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>Abrir modal</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
Un botón, a la vez Tooltip Trigger y Dialog Trigger. Dos comportamientos superpuestos, sin problema.
Gestión del foco y navegación por teclado
La gestión del foco es la parte de accesibilidad que más se ignora.
Mucha gente solo piensa en «que se vea bien» y olvida que hay usuarios que no usan ratón. Usuarios de teclado y de lectores de pantalla dependen por completo de dónde está el foco.
Radix automatiza mucho de esto. Un ejemplo:
Al abrir AlertDialog, el foco se mueve automáticamente al botón Cancel.
Es un detalle deliberado. AlertDialog suele confirmar acciones peligrosas (borrar, salir). Tras abrirlo, lo más probable es «cancelar», no «confirmar». Con el foco en Cancel, basta Enter para cerrar y evitar errores.
¿Y si el foco quedara en Confirm? El usuario pulsa Enter sin querer y ejecuta el borrado. Desastre.
Ese comportamiento sigue las WAI-ARIA authoring practices. No tienes que escribirlo tú.
Pero hay un problema: si personalizas el contenido de AlertDialog, el foco puede ir mal.
Por ejemplo, añades un campo de texto:
<AlertDialog.Content>
<AlertDialog.Title>¿Confirmar borrado?</AlertDialog.Title>
<AlertDialog.Description>Escribe DELETE para confirmar</AlertDialog.Description>
<input placeholder="Escribe DELETE" /> {/* lo añadiste tú */}
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action>Confirmar</AlertDialog.Action>
</AlertDialog.Content>
Al abrir el modal, ¿adónde va el foco?
Radix busca por defecto el primer elemento enfocable. Tu input está antes que Cancel, así que el foco cae en el input. Hay que pulsar Tab varias veces para llegar a Cancel. Se rompe el flujo esperado.
Solución: usa autoFocus para fijar el objetivo o reordena los elementos.
<AlertDialog.Content>
<AlertDialog.Title>¿Confirmar borrado?</AlertDialog.Title>
<AlertDialog.Description>Escribe DELETE para confirmar</AlertDialog.Description>
<AlertDialog.Cancel autoFocus>Cancelar</AlertDialog.Cancel> {/* foco forzado */}
<input placeholder="Escribe DELETE" />
<AlertDialog.Action>Confirmar</AlertDialog.Action>
</AlertDialog.Content>
Mueve Cancel al frente o ponle autoFocus. Así el foco no se va de sitio.
La navegación por teclado tiene problemas similares.
Tabs: el usuario cambia pestañas con flechas izquierda/derecha, comportamiento estándar WAI-ARIA. Si añades estilos y sobrescribes role="tab" por error, la navegación por teclado falla.
Dropdown Menu: flechas arriba/abajo eligen ítems, Enter confirma, Esc cierra. Radix lo gestiona por dentro. Pero si pones onClick en lugar de onSelect en un ítem, puedes romper el comportamiento por teclado.
El método de prueba es directo: deja el ratón y opera todo el componente solo con teclado.
- ¿Tab entra en el componente?
- ¿Las flechas cambian opciones?
- ¿Enter dispara acciones?
- ¿Esc cierra el modal?
Si algún paso se atasca, hay un problema de accesibilidad.
Herencia automática de atributos ARIA
En ARIA, Radix te ahorra trabajo.
Añade automáticamente role y atributos aria-* correctos. Por ejemplo:
- Dialog recibe
role="dialog"yaria-modal="true" - Tabs.Tab recibe
role="tab"yaria-selected - Switch recibe
role="switch"yaria-checked
No tienes que encargarte de eso; Radix lo resuelve por dentro.
Pero hay algo que sí debes hacer: dar a los controles un nombre accesible.
Los usuarios de lectores de pantalla necesitan saber qué es ese botón, cómo se llama ese modal o qué va en ese campo. Sin nombre, solo pueden adivinar.
Radix ofrece el primitive Label:
<Label.Root htmlFor="email-input">Correo electrónico</Label.Root>
<Input id="email-input" />
Label.Root se asocia al input; el lector dirá «Correo electrónico» antes del valor del campo.
En controles personalizados (no inputs nativos), el nombre lo das tú:
<Switch aria-label="Activar modo oscuro" />
<Tabs.Tab aria-label="Detalles del producto" />
O con aria-labelledby vinculado a texto visible:
<div id="mode-label">Modo oscuro</div>
<Switch aria-labelledby="mode-label" />
Verificación: abre un lector de pantalla y prueba.
En Mac, VoiceOver (Cmd+F5); en Windows, NVDA (descarga gratuita). Escucha cómo lee tus componentes. Si oyes «botón» y no «botón Enviar pedido», falta el nombre accesible.
Un detalle más: contraste de color.
Radix no gestiona estilos, así que el contraste es tu responsabilidad. WCAG exige al menos 4,5:1 entre texto y fondo (3:1 en texto grande). Los colores por defecto de shadcn/ui suelen cumplirlo, pero ten cuidado al cambiar colores.
Hay una herramienta, WebAIM Contrast Checker: introduces color de primer plano y fondo y calcula el contraste.
Lista de comprobación práctica
Tras personalizar un componente shadcn/ui, repasa esta lista:
Comprobaciones de asChild
- ¿El hijo de
asChildes enfocable? (button/a/input, no div) - ¿Tu componente hace spread de todas las props?
- ¿Tu componente hace forward de ref?
Comprobaciones de gestión del foco
- ¿Al abrir el modal, el foco va al sitio correcto?
- ¿Al cerrarlo, vuelve al elemento que lo abrió?
- ¿Con elementos enfocables anidados, el orden del foco es lógico?
Comprobaciones de navegación por teclado
- ¿Tab entra en el componente?
- ¿Las flechas cambian opciones (Tabs, Dropdown)?
- ¿Enter dispara acciones?
- ¿Esc cierra el modal?
- ¿Espacio cambia estado (Switch, Checkbox)?
Comprobaciones ARIA
- ¿Cada control tiene nombre accesible?
- ¿El lector anuncia bien rol y estado?
- ¿Los cambios dinámicos tienen regiones aria-live correctas?
Comprobaciones visuales
- ¿El indicador de foco se ve claro?
- ¿El contraste cumple (4,5:1 o 3:1)?
- ¿No solo se usa color para transmitir información (también iconos o texto)?
Herramientas de prueba
- Prueba con teclado: sin ratón, todo el flujo solo con teclado
- Lector de pantalla: VoiceOver (Mac) o NVDA (Windows)
- Automatizado: extensión axe DevTools
Conclusión
shadcn/ui te da libertad sobre el código, pero esa libertad tiene límites.
El límite es el comportamiento accesible de Radix. Puedes cambiar estilos, layout y clases, pero no la lógica de comportamiento de abajo. Cambias button por div o olvidas el spread de props y los usuarios de teclado lo pagan.
Recuerda:
- Con asChild: el hijo debe ser enfocable; componentes personalizados con spread props + forward ref
- Gestión del foco: al personalizar modales, mira adónde va el foco
- Atributos ARIA: Radix añade role; tú aportas la etiqueta
La próxima vez que modifiques un componente, pruébalo antes con teclado. Corrige al momento; no esperes a QA.
En definitiva, la accesibilidad no es un «extra», es un requisito base. shadcn/ui y Radix ya hicieron lo más difícil; no deshagas ese trabajo.
FAQ
¿Qué relación hay entre shadcn/ui y Radix UI?
¿Cómo usar asChild sin romper la accesibilidad?
¿Qué hay que vigilar en la gestión del foco al personalizar ventanas modales?
¿Cómo comprobar si la accesibilidad del componente funciona bien?
Si Radix añade atributos aria automáticamente, ¿qué más debo hacer?
10 min de lectura · Publicado el: 30 mar 2026 · Actualizado el: 21 ago 2026
Tailwind y shadcn/ui en práctica
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Patrones de composición en shadcn/ui: mejores prácticas para que varios componentes trabajen juntos
Aprende las mejores prácticas de los patrones de composición de shadcn/ui: Dialog+Form, DataTable+DropdownMenu y escenarios habituales, con el patrón Context, gestión de estado y optimización del rendimiento
Parte 8 de 14
Siguiente
Dialog, Sheet y Popover: accesibilidad y gestión del foco en capas modales
Análisis profundo de la accesibilidad y la gestión del foco en Dialog, Sheet y Popover de shadcn/ui: estándares WCAG, atributos ARIA, navegación por teclado y trampa de foco, con ejemplos de código completos
Parte 10 de 14



Comentarios
Inicia sesión con GitHub para dejar un comentario