React accesible con Claude Code y Radix UI
Implementa Dialog, Dropdown y Tabs accesibles con Claude Code y Radix UI: instalación, ejemplos React, estilos, errores y checklist.
Crear un modal, un menú desplegable o unas pestañas en React parece sencillo hasta que pruebas la interfaz sin ratón. Entonces aparecen los problemas: el foco no entra en el diálogo, Escape no cierra, el lector de pantalla no anuncia el título, el foco vuelve a un lugar inesperado o el menú se sale de la pantalla en móvil.
Radix UI encaja muy bien en este tipo de trabajo porque no intenta imponer una estética. Proporciona primitives sin estilos: piezas de bajo nivel con comportamiento accesible que tú puedes vestir con CSS, Tailwind o el sistema de diseño del proyecto. Con Claude Code, esto reduce mucho el riesgo. En vez de pedir “haz un modal desde cero”, puedes pedir “usa Radix UI y conserva el comportamiento accesible”.
Esta guía explica cómo usar Claude Code con Radix UI para crear Dialog, Dropdown Menu y Tabs en React con TypeScript. Incluye comandos de instalación, un prompt listo para pegar, código copiables, notas de estilo, tres casos de uso, trampas comunes, enlaces oficiales y CTA de monetización. Si quieres una capa más opinada sobre Radix, lee la guía de shadcn/ui con Claude Code. Para revisar accesibilidad de forma más amplia, complementa con la guía de accesibilidad con Claude Code.
Por qué Radix UI encaja con Claude Code
La documentación oficial describe Radix Primitives como una librería de componentes de bajo nivel centrada en accesibilidad, personalización y experiencia de desarrollo. La idea no es copiar un diseño terminado, sino reutilizar la parte más difícil de los patrones interactivos: roles, foco, navegación con teclado y estructura del componente.
Radix Dialog, por ejemplo, soporta modo modal y no modal, atrapa el foco dentro del modal, cierra con Escape y usa Title y Description para mejorar el anuncio en lectores de pantalla. Radix Tabs sigue el patrón WAI-ARIA de pestañas y gestiona flechas, Home y End. Dropdown Menu ofrece etiquetas, separadores, radio items y submenús sin escribir todo el sistema de foco desde cero.
Claude Code trabaja leyendo contexto, editando y verificando. Si le pides que invente todo el comportamiento con div, el diff será más largo y más difícil de revisar. Si le das Radix UI como base, puede concentrarse en el estado de React, llamadas API, clases CSS, eventos de analítica y pruebas.
flowchart LR
A["Explicar el requisito a Claude Code"] --> B["Usar Radix UI para el comportamiento"]
B --> C["Conectar estado de React y lógica del producto"]
C --> D["Aplicar CSS o tokens del proyecto"]
D --> E["Verificar teclado, lector de pantalla y móvil"]
Instalación
Radix también documenta el paquete unificado radix-ui, pero muchos proyectos React usan paquetes individuales. Aquí usamos paquetes individuales para que package.json muestre claramente qué primitives se usan.
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs
Con pnpm:
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs
Si aún no tienes Claude Code, revisa la documentación oficial. La instalación con npm es:
npm install -g @anthropic-ai/claude-code
En equipos conviene definir antes permisos, actualización, revisión humana y comandos permitidos. Instalar la herramienta no reemplaza una política de revisión.
Prompt recomendado para Claude Code
Un buen prompt no pide solo apariencia. Describe el contrato de interacción que no debe romperse.
claude "Añade a la pantalla React + TypeScript existente un Dialog de confirmación, un Dropdown Menu de usuario y Tabs de ajustes.
Condiciones:
- Usa @radix-ui/react-dialog, @radix-ui/react-dropdown-menu y @radix-ui/react-tabs
- Mantén Dialog.Title y Dialog.Description
- Los botones de cierre solo con icono deben tener aria-label
- No elimines el foco visible; usa :focus-visible
- Reutiliza design tokens existentes si los hay
- Al final lista checks de teclado, móvil, typecheck y accesibilidad"
Después mira el diff. Comprueba que asChild no haya generado botones anidados, que Dialog.Title no haya desaparecido por motivos visuales y que el CSS no haya ocultado el foco sin reemplazo visible.
Ejemplo React copiable
El siguiente archivo contiene tres piezas: un diálogo de confirmación, un menú de usuario y unas pestañas de ajustes. Sirve para Vite, React SPA o Next.js Client Component. En Next.js App Router, añade "use client"; al principio.
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tabs from "@radix-ui/react-tabs";
import "./radix-accessible-demo.css";
type User = { name: string; email: string };
type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => Promise<void> | void;
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
danger = false,
onConfirm,
}: ConfirmDialogProps) {
const [pending, setPending] = React.useState(false);
async function handleConfirm() {
setPending(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setPending(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="radix-overlay" />
<Dialog.Content className="radix-dialog">
<Dialog.Title className="radix-dialog-title">{title}</Dialog.Title>
<Dialog.Description className="radix-dialog-description">
{description}
</Dialog.Description>
<div className="button-row">
<Dialog.Close asChild>
<button type="button" className="button secondary">Cancel</button>
</Dialog.Close>
<button
type="button"
className={`button ${danger ? "danger" : "primary"}`}
disabled={pending}
onClick={handleConfirm}
>
{pending ? "Working..." : confirmLabel}
</button>
</div>
<Dialog.Close asChild>
<button type="button" className="icon-button" aria-label="Close dialog">
x
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function UserMenu({
user,
onOpenProfile,
onOpenBilling,
onSignOut,
}: {
user: User;
onOpenProfile: () => void;
onOpenBilling: () => void;
onSignOut: () => void;
}) {
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system");
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className="user-trigger" aria-label={`${user.name} menu`}>
<span className="avatar" aria-hidden="true">{user.name.slice(0, 1)}</span>
<span>{user.name}</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="dropdown-content" align="end" sideOffset={8}>
<DropdownMenu.Label className="dropdown-label">{user.email}</DropdownMenu.Label>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item" onSelect={() => onOpenProfile()}>
Profile
</DropdownMenu.Item>
<DropdownMenu.Item className="dropdown-item" onSelect={() => onOpenBilling()}>
Billing
</DropdownMenu.Item>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.RadioGroup
value={theme}
onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}
>
<DropdownMenu.RadioItem className="dropdown-item" value="light">Light</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem className="dropdown-item" value="dark">Dark</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem className="dropdown-item" value="system">System</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item danger-text" onSelect={() => onSignOut()}>
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
export function SettingsTabs() {
return (
<Tabs.Root defaultValue="profile" className="tabs-root">
<Tabs.List className="tabs-list" aria-label="Account settings">
<Tabs.Trigger className="tabs-trigger" value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger className="tabs-trigger" value="security">Security</Tabs.Trigger>
<Tabs.Trigger className="tabs-trigger" value="notifications">Notifications</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="tabs-content" value="profile">
<label className="field">
<span>Display name</span>
<input defaultValue="Masa" />
</label>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="security">
<p>Require two-factor authentication before changing billing settings.</p>
<button type="button" className="button secondary">Review security</button>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="notifications">
<label className="check-row">
<input type="checkbox" defaultChecked />
<span>Email me when a project is exported.</span>
</label>
</Tabs.Content>
</Tabs.Root>
);
}
Notas de estilo
Radix no trae estilos. Puedes usar Tailwind, CSS Modules, CSS plano o tokens. Lo importante es conservar un foco visible, limitar el ancho del Dialog en móvil y marcar claramente los estados de menú y tabs.
.radix-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
}
.radix-dialog {
position: fixed;
left: 50%;
top: 50%;
width: min(calc(100vw - 32px), 480px);
max-height: calc(100vh - 32px);
overflow: auto;
transform: translate(-50%, -50%);
border-radius: 8px;
background: white;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
padding: 24px;
}
.button-row {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.dropdown-content {
min-width: 220px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
padding: 6px;
}
.dropdown-item {
border-radius: 6px;
cursor: pointer;
outline: none;
padding: 8px 10px;
}
.dropdown-item[data-highlighted] {
background: #eff6ff;
color: #1d4ed8;
}
.tabs-list {
display: flex;
border-bottom: 1px solid #e2e8f0;
gap: 4px;
}
.tabs-trigger {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
cursor: pointer;
padding: 10px 12px;
}
.tabs-trigger[data-state="active"] {
border-color: #2563eb;
color: #1d4ed8;
font-weight: 700;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 2px;
}
Tres casos de uso
| Caso | Por qué ayuda Radix UI | Qué debe conectar Claude Code |
|---|---|---|
| Confirmar borrado o cancelación | Controla foco, título y descripción | API, estado pendiente, errores y tests |
| Menú de cuenta | Navegación con teclado, separadores y radio items | Usuario, logout, billing, analítica |
| Tabs de ajustes | Mantiene la relación tablist, tab y panel | Formularios, guardado, URL y estado sucio |
El primer caso es un panel SaaS. Borrar un proyecto, cancelar una suscripción o cambiar permisos requiere claridad, no solo animación. Radix Dialog aporta el comportamiento base y Claude Code conecta el flujo real.
El segundo caso es un sitio de cursos, medios o membresía. El menú de cuenta suele contener perfil, descargas, historial de compra y salida. Un menú solo con clic deja fuera a usuarios de teclado.
El tercer caso es una pantalla de ajustes. Tabs ayuda a separar perfil, seguridad y notificaciones, pero debes aclarar qué se guarda en cada sección. Pide a Claude Code que muestre estado sin guardar si aplica.
Errores frecuentes
No elimines Dialog.Title por estética. Si el título no debe verse, mantenlo oculto visualmente para lectores de pantalla. La documentación de MDN sobre dialog recuerda que un diálogo necesita nombre y gestión correcta de foco.
No ocultes el foco. Cambiar el outline por un estilo de marca está bien; dejarlo invisible no.
Revisa asChild. Si aparece un button dentro de otro button, el HTML es inválido y el comportamiento puede variar entre navegadores.
Evita apilar modales salvo que sea imprescindible. El patrón WAI-ARIA de diálogo modal espera que el foco entre al diálogo y vuelva al disparador al cerrar.
Comprueba móvil. Un modal fijo de 600px, un menú que se sale por la derecha o tabs con etiquetas largas pueden romper una página que en desktop parecía correcta.
Enlaces oficiales y checks
- Radix Primitives Introduction
- Radix Dialog docs
- Radix Dropdown Menu docs
- Radix Tabs docs
- WAI-ARIA Modal Dialog Pattern
- MDN dialog role
- Claude Code getting started
Antes de publicar, abre y cierra el Dialog solo con teclado, confirma que Escape funciona, que el foco vuelve al botón inicial, que el Dropdown se navega con flechas, que Tabs cambia con flechas y que no hay overflow en móvil.
CTA de monetización
Este tema convierte bien porque el lector suele tener un proyecto real. La oferta natural no es “compra una librería”, sino “trae tu repositorio y hagamos que la UI sea revisable y accesible”.
ClaudeCodeLab puede ayudar con formación y consultoría Claude Code: CLAUDE.md, reglas de componentes, checks de accesibilidad, prompts de review y scripts de verificación. Para desarrolladores individuales, conecta con plantillas y checklist; para equipos, ofrece una sesión de revisión sobre una pantalla real.
Resultado probado
En una pantalla React de prueba de Masa, reemplazar un modal manual por Radix Dialog y mover el menú y los tabs a Radix aumentó un poco el código, pero hizo mucho más clara la revisión. Pedir a Claude Code que revisara retorno de foco, nombres para lectores de pantalla, ancho móvil y teclado dio mejores observaciones que pedir solo “mejorar el diseño”. La conclusión: Radix UI no sustituye pensar en accesibilidad; le da a Claude Code una base más segura para trabajar.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.