Como Criar um Design System com Radix UI e Tailwind CSS
O problema real: componentes bonitos que quebram no leitor de tela
A maioria dos design systems internos nasce de um diretório components/ que cresce sem contrato. Um Button aceita 14 props, um Modal não trapa foco, um Select customizado ignora navegação por teclado. O time corrige acessibilidade caso a caso, e cada correção introduz lógica imperativa frágil.
Radix UI resolve a parte difícil: comportamento, acessibilidade (WAI-ARIA) e gerenciamento de foco. Tailwind CSS resolve a parte visual sem CSS-in-JS em runtime. O trabalho que sobra é criar uma camada de variantes tipada que conecte os dois e exponha uma API previsível para quem consome.
Este post monta essa camada do zero, com código funcional em React e TypeScript.
Anatomia da stack: quem faz o quê
| Responsabilidade | Radix UI | Tailwind CSS | Seu código |
|---|---|---|---|
| Semântica HTML e ARIA | Sim | Não | Não |
| Gerenciamento de foco (trap, restore) | Sim | Não | Não |
| Animações de entrada/saída | Parcial (data attributes) | Sim (classes utilitárias) | Conecta os dois |
| Tokens visuais (cor, espaçamento, tipografia) | Não | Sim (theme config) | Define os tokens |
| API de variantes tipada | Não | Não | Sim (com cva ou equivalente) |
| Composição de componentes | Sim (compound components) | Não se aplica | Encapsula o padrão |
A divisão é clara: Radix não opina sobre visual, Tailwind não opina sobre comportamento. O seu design system é a cola tipada entre os dois.
Configuração inicial do projeto
# Cria o projeto com Vite e React + TypeScript
npm create vite@latest ds-demo -- --template react-ts
cd ds-demo
# Radix UI: instala apenas os primitivos que você vai usar
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-slot
# Tailwind CSS v4 (se estiver no v3, o fluxo de config muda)
npm install -D tailwindcss @tailwindcss/vite
# CVA para variantes tipadas + tailwind-merge para resolver conflitos de classe
npm install class-variance-authority tailwind-merge clsx
// src/lib/cn.ts
// Utilitário que combina clsx (condicionais) com twMerge (resolve conflitos de especificidade do Tailwind)
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
// twMerge garante que "px-4 px-6" resulte em "px-6", não em ambas as classes
return twMerge(clsx(inputs));
}
Esse cn é a função mais usada do design system inteiro. Sem tailwind-merge, quem consome o componente e passa className extra não consegue sobrescrever padding ou cor de forma previsível.
Tokens de design no Tailwind
Antes de criar componentes, defina os tokens. No Tailwind v4, a configuração migrou para CSS. Se você usa v3, o equivalente fica em tailwind.config.ts.
/* src/app.css — Tailwind v4 com tokens customizados */
@import "tailwindcss";
@theme {
/* Cores semânticas: nomeie pela função, não pelo valor visual */
--color-surface: #ffffff;
--color-surface-raised: #f8f9fa;
--color-border-default: #e2e4e9;
--color-border-strong: #c1c4cc;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-foreground: #ffffff;
--color-destructive: #dc2626;
--color-destructive-hover: #b91c1c;
--color-destructive-foreground: #ffffff;
--color-muted: #6b7280;
--color-muted-foreground: #374151;
/* Espaçamentos extras além dos defaults do Tailwind */
--spacing-4_5: 1.125rem;
/* Raios */
--radius-component: 0.5rem;
--radius-pill: 9999px;
}
Nomear tokens por função (primary, destructive, surface) e não por cor (blue-600, red-600) permite trocar o tema inteiro sem tocar em componentes.
Componente Button: variantes tipadas com CVA
// src/components/ui/button.tsx
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/cn";
// CVA de
---
Leia o artigo completo em [https://www.vivodecodigo.com.br/react/design-system-radix-ui-tailwind-css](https://www.vivodecodigo.com.br/react/design-system-radix-ui-tailwind-css)