Por que Radix UI para sistemas de diseno
Construir componentes de UI accesibles y listos para produccion desde cero es uno de los desafios mas complejos del desarrollo frontend. Necesitas gestionar la navegacion por teclado, los anuncios para lectores de pantalla, el manejo de foco, los atributos ARIA y casos extremos en los que la mayoria de desarrolladores ni siquiera piensan. Radix UI resuelve esto proporcionando primitivas sin estilos y accesibles que puedes componer en un sistema de diseno personalizado con control total sobre el estilo y el comportamiento.
A diferencia de bibliotecas de componentes como Material UI o Chakra UI, Radix UI es headless — proporciona comportamiento y accesibilidad sin imponer ningun diseno visual. Esto lo convierte en la base perfecta para un sistema de diseno basado en Tailwind CSS donde quieres control absoluto sobre cada elemento.
Las ventajas clave de Radix UI incluyen:
- Accesible por defecto: Cada primitiva sigue los patrones WAI-ARIA con roles, estados e interacciones de teclado incorporados.
- Composable: Los componentes estan construidos a partir de partes pequenas y enfocadas que puedes organizar y personalizar libremente.
- Sin estilos: No incluye CSS — tu aplicas tus propios estilos con Tailwind, CSS modules o cualquier enfoque.
- Adopcion incremental: Instala solo las primitivas que necesitas. Cada una es un paquete independiente.
- Compatible con SSR: Funciona con el App Router de Next.js y Server Components sin configuracion adicional.
Instalacion de primitivas Radix
Radix UI sigue una arquitectura modular donde cada componente es un paquete independiente. Instalas solo lo que necesitas, manteniendo el tamano del bundle al minimo. Estas son las primitivas mas utilizadas para un sistema de diseno:
# Primitivas esenciales para un sistema de diseno
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tabs
npm install @radix-ui/react-tooltip
npm install @radix-ui/react-popover
npm install @radix-ui/react-select
npm install @radix-ui/react-toggle
npm install @radix-ui/react-switch
npm install @radix-ui/react-accordion
npm install @radix-ui/react-navigation-menu
npm install @radix-ui/react-toast
# O instala varias a la vez
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs @radix-ui/react-tooltip @radix-ui/react-popover
Tambien necesitaras la utilidad cn() para la combinacion condicional de clases,
que configuraremos en una seccion posterior. Asegurate de tener Tailwind CSS configurado en
tu proyecto Next.js antes de continuar.
Configuracion de la utilidad cn()
Antes de construir cualquier componente, configura la utilidad cn() que combina
clsx para clases condicionales con tailwind-merge para la resolucion
inteligente de conflictos de clases. Esta es la base de todo componente Tailwind bien construido.
npm install clsx tailwind-merge// utils/classNames.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
La funcion cn() garantiza que cuando se pasan dos clases de Tailwind en conflicto,
la ultima gana. Por ejemplo, cn("p-4", "p-6") devuelve "p-6" en lugar
de incluir ambas clases. Esto es esencial para construir componentes composables donde los
componentes padre pueden sobreescribir los estilos del hijo.
Construir un componente Dialog
El Dialog (o modal) es uno de los patrones de UI mas complejos de implementar correctamente. Requiere captura de foco, bloqueo de scroll, atributos ARIA adecuados y manejo de la tecla Escape. Radix UI gestiona todo esto, y nosotros solo anadimos estilos de Tailwind encima.
// components/ui/Dialog.tsx
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { IconX } from "@tabler/icons-react";
import { cn } from "@/utils/classNames";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
"rounded-xl border border-gray-200 bg-white p-6 shadow-2xl",
"dark:border-gray-700 dark:bg-gray-900",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
"duration-200",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 rounded-sm opacity-70 transition-opacity",
"hover:opacity-100",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
"focus-visible:ring-offset-2",
"disabled:pointer-events-none"
)}
aria-label="Cerrar dialogo"
>
<IconX className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
};El uso del componente Dialog es limpio y declarativo:
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/Dialog";
export function ContactDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<button className="rounded-lg bg-primary-700 px-6 py-3 text-white hover:bg-primary-800">
Contactar
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Contactanos</DialogTitle>
<DialogDescription>
Completa el formulario y te responderemos en menos de 24 horas.
</DialogDescription>
</DialogHeader>
<form className="mt-4 space-y-4">
<input
type="email"
placeholder="tu@email.com"
className="w-full rounded-lg border border-gray-300 px-4 py-2
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
aria-label="Direccion de correo electronico"
/>
<textarea
placeholder="Tu mensaje..."
rows={4}
className="w-full rounded-lg border border-gray-300 px-4 py-2
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
aria-label="Mensaje"
/>
<button
type="submit"
className="w-full rounded-lg bg-primary-700 px-6 py-3 text-white hover:bg-primary-800"
>
Enviar mensaje
</button>
</form>
</DialogContent>
</Dialog>
);
}Construir un Dropdown Menu
Los menus desplegables requieren navegacion por teclado compleja: teclas de flecha para moverse entre elementos, Enter o Espacio para seleccionar, Escape para cerrar y busqueda por escritura rapida. Radix UI proporciona todo esto de forma nativa.
// components/ui/DropdownMenu.tsx
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { IconCheck, IconChevronRight, IconCircle } from "@tabler/icons-react";
import { cn } from "@/utils/classNames";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 shadow-lg",
"dark:border-gray-700 dark:bg-gray-900",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm",
"outline-none transition-colors",
"focus:bg-gray-100 focus:text-gray-900",
"dark:focus:bg-gray-800 dark:focus:text-gray-100",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-100",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuRadioGroup,
};Ejemplo practico de un menu de usuario desplegable:
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/DropdownMenu";
import { IconUser, IconSettings, IconLogout } from "@tabler/icons-react";
export function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-2 rounded-full bg-gray-100 px-3 py-2
hover:bg-gray-200 focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-500"
aria-label="Abrir menu de usuario"
>
<IconUser className="h-5 w-5" />
<span className="text-sm font-medium">Ray Martin</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<IconUser className="mr-2 h-4 w-4" />
<span>Perfil</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconSettings className="mr-2 h-4 w-4" />
<span>Configuracion</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 focus:text-red-700">
<IconLogout className="mr-2 h-4 w-4" />
<span>Cerrar sesion</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Componente Tabs con indicador animado
Las pestanas son uno de los patrones de navegacion mas versatiles. Radix proporciona la base de accesibilidad — incluyendo navegacion con teclas de flecha entre pestanas, activacion automatica y roles ARIA adecuados — mientras nosotros anadimos un indicador animado suave con Tailwind.
// components/ui/Tabs.tsx
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/utils/classNames";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md",
"bg-gray-100 p-1 text-gray-500",
"dark:bg-gray-800 dark:text-gray-400",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5",
"text-sm font-medium ring-offset-white transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
"focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-white data-[state=active]:text-gray-900",
"data-[state=active]:shadow-sm",
"dark:ring-offset-gray-900 dark:data-[state=active]:bg-gray-900",
"dark:data-[state=active]:text-gray-100",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
"focus-visible:ring-offset-2",
"dark:ring-offset-gray-900",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };Para un componente de pestanas mas avanzado con un indicador deslizante animado, puedes rastrear la posicion de la pestana activa y animar un elemento de resaltado:
// components/ui/AnimatedTabs.tsx
"use client";
import { useState, useRef, useEffect } from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/utils/classNames";
interface Tab {
value: string;
label: string;
}
interface AnimatedTabsProps {
tabs: Tab[];
defaultValue: string;
children: React.ReactNode;
}
export function AnimatedTabs({ tabs, defaultValue, children }: AnimatedTabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
const tabsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
useEffect(() => {
const activeElement = tabsRef.current.get(activeTab);
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement;
setIndicatorStyle({ left: offsetLeft, width: offsetWidth });
}
}, [activeTab]);
return (
<TabsPrimitive.Root value={activeTab} onValueChange={setActiveTab}>
<TabsPrimitive.List className="relative flex border-b border-gray-200 dark:border-gray-700">
{tabs.map((tab) => (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
ref={(el) => {
if (el) tabsRef.current.set(tab.value, el);
}}
className={cn(
"relative z-10 px-4 py-2 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
activeTab === tab.value
? "text-primary-700 dark:text-primary-400"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
)}
>
{tab.label}
</TabsPrimitive.Trigger>
))}
<div
className="absolute bottom-0 h-0.5 bg-primary-700 transition-all duration-300 ease-out dark:bg-primary-400"
style={{ left: indicatorStyle.left, width: indicatorStyle.width }}
aria-hidden="true"
/>
</TabsPrimitive.List>
{children}
</TabsPrimitive.Root>
);
}Tooltip con atributos ARIA adecuados
Los tooltips proporcionan informacion complementaria al pasar el cursor o enfocar un elemento.
Radix gestiona automaticamente el timing, el posicionamiento y las relaciones ARIA. El tooltip
utiliza role="tooltip" y se conecta al trigger mediante aria-describedby.
// components/ui/Tooltip.tsx
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/utils/classNames";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-gray-200 bg-white px-3 py-1.5",
"text-sm text-gray-900 shadow-md",
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100",
"animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2",
"data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
Envuelve tu aplicacion en un TooltipProvider con un delay global y luego usa
tooltips en todos tus componentes:
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/Tooltip";
import { IconCopy, IconCheck } from "@tabler/icons-react";
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopy}
className="rounded-md p-2 hover:bg-gray-100 focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={copied ? "Copiado al portapapeles" : "Copiar al portapapeles"}
>
{copied ? (
<IconCheck className="h-4 w-4 text-green-600" />
) : (
<IconCopy className="h-4 w-4 text-gray-600" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copiado!" : "Copiar al portapapeles"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}Popover para contenido complejo
Mientras que los tooltips son para sugerencias de texto simples, los Popovers pueden contener cualquier contenido — formularios, listas, texto enriquecido o elementos interactivos. A diferencia de los tooltips, los popovers se activan haciendo clic (no al pasar el cursor) y permanecen abiertos hasta que se cierran explicitamente.
// components/ui/Popover.tsx
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/utils/classNames";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-gray-200 bg-white p-4 shadow-lg outline-none",
"dark:border-gray-700 dark:bg-gray-900",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2",
"data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };Un ejemplo practico de un popover de filtros con controles de formulario:
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/Popover";
import { IconFilter } from "@tabler/icons-react";
export function FilterPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2
text-sm hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-500"
aria-label="Abrir opciones de filtro"
>
<IconFilter className="h-4 w-4" />
Filtros
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-4">
<h4 className="text-sm font-semibold">Filtrar proyectos</h4>
<div className="space-y-2">
<label className="text-sm text-gray-600">Categoria</label>
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label="Filtrar por categoria"
>
<option value="">Todas las categorias</option>
<option value="web">Desarrollo web</option>
<option value="mobile">Apps moviles</option>
<option value="design">Sistemas de diseno</option>
</select>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="featured" className="rounded border-gray-300" />
<label htmlFor="featured" className="text-sm text-gray-600">Solo destacados</label>
</div>
</div>
</PopoverContent>
</Popover>
);
}Tematizacion con variables CSS y Tailwind
Un sistema de diseno robusto necesita una capa de tematizacion que pueda adaptarse a diferentes marcas, modo oscuro y preferencias del usuario. El mejor enfoque combina propiedades CSS personalizadas con la configuracion de Tailwind para crear una unica fuente de verdad para todos los tokens de diseno.
/* styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 255 255 255;
--foreground: 15 23 42;
--card: 249 250 251;
--card-foreground: 15 23 42;
--primary: 12 74 110;
--primary-foreground: 255 255 255;
--secondary: 241 245 249;
--secondary-foreground: 15 23 42;
--muted: 241 245 249;
--muted-foreground: 100 116 139;
--destructive: 239 68 68;
--destructive-foreground: 255 255 255;
--border: 226 232 240;
--input: 226 232 240;
--ring: 14 165 233;
--radius: 0.5rem;
}
.dark {
--background: 15 23 42;
--foreground: 248 250 252;
--card: 30 41 59;
--card-foreground: 248 250 252;
--primary: 56 189 248;
--primary-foreground: 15 23 42;
--secondary: 30 41 59;
--secondary-foreground: 248 250 252;
--muted: 30 41 59;
--muted-foreground: 148 163 184;
--destructive: 239 68 68;
--destructive-foreground: 255 255 255;
--border: 51 65 85;
--input: 51 65 85;
--ring: 56 189 248;
}
}// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "rgb(var(--background) / <alpha-value>)",
foreground: "rgb(var(--foreground) / <alpha-value>)",
card: {
DEFAULT: "rgb(var(--card) / <alpha-value>)",
foreground: "rgb(var(--card-foreground) / <alpha-value>)",
},
primary: {
DEFAULT: "rgb(var(--primary) / <alpha-value>)",
foreground: "rgb(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "rgb(var(--secondary) / <alpha-value>)",
foreground: "rgb(var(--secondary-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "rgb(var(--muted) / <alpha-value>)",
foreground: "rgb(var(--muted-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "rgb(var(--destructive) / <alpha-value>)",
foreground: "rgb(var(--destructive-foreground) / <alpha-value>)",
},
border: "rgb(var(--border) / <alpha-value>)",
input: "rgb(var(--input) / <alpha-value>)",
ring: "rgb(var(--ring) / <alpha-value>)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;
Con esta configuracion, todos los componentes Radix se adaptan automaticamente al tema activo.
Puedes usar clases como bg-background, text-foreground,
border-border y bg-primary en todos tus componentes, y se resolveran
a los valores correctos segun el tema activo.
Cumplimiento WCAG 2.2
Radix UI gestiona la mayoria de las preocupaciones de accesibilidad automaticamente, pero hay buenas practicas adicionales que debes seguir al construir tu sistema de diseno para cumplir con WCAG 2.2.
Estilos focus-visible
Cada elemento interactivo debe tener un indicador de foco visible para los usuarios de teclado.
Usa focus-visible en lugar de focus para evitar mostrar anillos de
foco en los clics del raton:
// Estilos de foco base para todos los elementos interactivos
const focusStyles = cn(
"focus-visible:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-ring",
"focus-visible:ring-offset-2",
"focus-visible:ring-offset-background"
);
// Aplicar a botones, enlaces, inputs y cualquier elemento interactivo
<button className={cn("rounded-lg px-4 py-2", focusStyles)}>
Haz clic
</button>
<a href="/about" className={cn("text-primary underline", focusStyles)}>
Saber mas
</a>Etiquetas ARIA y roles
Mientras que las primitivas de Radix manejan los atributos ARIA internamente, debes proporcionar etiquetas significativas para tus elementos trigger y cualquier componente interactivo personalizado:
// Siempre proporciona aria-label para botones solo con iconos
<DialogTrigger asChild>
<button aria-label="Abrir dialogo de configuracion">
<IconSettings className="h-5 w-5" />
</button>
</DialogTrigger>
// Usa aria-describedby para contexto adicional
<DialogContent aria-describedby="dialog-description">
<DialogTitle>Eliminar cuenta</DialogTitle>
<DialogDescription id="dialog-description">
Esta accion no se puede deshacer. Todos tus datos seran eliminados permanentemente.
</DialogDescription>
</DialogContent>
// Texto solo para lectores de pantalla en elementos visuales
<span className="sr-only">Cargando, por favor espera</span>
// Regiones en vivo para actualizaciones de contenido dinamico
<div role="status" aria-live="polite" className="sr-only">
{message}
</div>Contraste de color y movimiento
- Ratios de contraste: El texto debe cumplir un ratio de contraste minimo de 4.5:1 para texto normal y 3:1 para texto grande respecto a su fondo. Usa herramientas como el verificador de contraste de WebAIM para comprobarlo.
- Indicadores de foco: Los anillos de foco deben tener un ratio de contraste de al menos 3:1 contra el fondo.
- Movimiento reducido: Envuelve todas las animaciones con la variante
motion-safepara respetarprefers-reduced-motion. - Objetivos tactiles: Los elementos interactivos deben tener un tamano minimo de 44x44 pixeles CSS para accesibilidad tactil.
// Respetar preferencias de movimiento reducido
<div className="motion-safe:animate-in motion-safe:fade-in-0 motion-reduce:animate-none">
{children}
</div>
// Tamano minimo de objetivo tactil
<button className="min-h-[44px] min-w-[44px] rounded-lg px-4 py-2">
Accion
</button>Componer primitivas en componentes de alto nivel
El verdadero poder de Radix UI emerge cuando compones multiples primitivas en componentes de alto nivel especificos del dominio. En lugar de exponer las primitivas Radix sin procesar a tu codigo de aplicacion, construye componentes con proposito especifico que encapsulen tanto el comportamiento como el estilo.
// components/common/ConfirmDialog.tsx
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/Dialog";
import { cn } from "@/utils/classNames";
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
onConfirm: () => void | Promise<void>;
trigger: React.ReactNode;
}
export function ConfirmDialog({
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
variant = "default",
onConfirm,
trigger,
}: ConfirmDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm();
setOpen(false);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="mt-6 flex justify-end gap-3">
<DialogClose asChild>
<button
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium
hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-500"
>
{cancelLabel}
</button>
</DialogClose>
<button
onClick={handleConfirm}
disabled={loading}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:opacity-50 disabled:cursor-not-allowed",
variant === "danger"
? "bg-red-600 hover:bg-red-700 focus-visible:ring-red-500"
: "bg-primary-700 hover:bg-primary-800 focus-visible:ring-primary-500"
)}
>
{loading ? "Procesando..." : confirmLabel}
</button>
</div>
</DialogContent>
</Dialog>
);
}El uso se vuelve extremadamente simple y consistente en toda tu aplicacion:
// Confirmacion antes de eliminar un proyecto
<ConfirmDialog
title="Eliminar proyecto"
description="Estas seguro de que quieres eliminar este proyecto? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
onConfirm={async () => {
await deleteProject(project.id);
}}
trigger={
<button className="text-red-600 hover:text-red-700">
Eliminar
</button>
}
/>Construir un sistema de diseno con Radix UI y Tailwind CSS te da lo mejor de ambos mundos: accesibilidad de nivel produccion de Radix, y control visual completo de Tailwind. Cada primitiva es un bloque de construccion que puedes componer en componentes cada vez mas complejos manteniendo la consistencia y la accesibilidad en toda tu aplicacion.