Saltar al contenido principal
Volver al blog

Construye un design system accesible con Radix UI y Tailwind

Ray MartínRay Martín
10 min de lectura
Construye un design system accesible con Radix UI y Tailwind

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:

bash
# 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.

bash
npm install clsx tailwind-merge
typescript
// 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.

typescript
// 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:

typescript
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.

typescript
// 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:

typescript
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.

typescript
// 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:

typescript
// 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.

typescript
// 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:

typescript
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.

typescript
// 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:

typescript
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.

css
/* 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;
  }
}
typescript
// 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:

typescript
// 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:

typescript
// 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-safe para respetar prefers-reduced-motion.
  • Objetivos tactiles: Los elementos interactivos deben tener un tamano minimo de 44x44 pixeles CSS para accesibilidad tactil.
typescript
// 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.

typescript
// 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:

typescript
// 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.

Compartir:

Artículos relacionados