Como Criar um Design System com Radix UI e Tailwind CSS

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
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 define variantes como mapa estático: zero lógica condicional espalhada no JSX
const buttonVariants = cva(
// Base: estilos compartilhados por TODAS as variantes
"inline-flex items-center justify-center gap-2 rounded-component text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary:
"bg-primary text-primary-foreground hover:bg-primary-hover",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive-hover",
outline:
"border border-border-default bg-surface hover:bg-surface-raised text-muted-foreground",
ghost:
"hover:bg-surface-raised text-muted-foreground",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
// Exporta o tipo para que outros componentes possam tipar props derivadas
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
// asChild delega a renderização para o filho direto via Radix Slot
asChild?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// Slot permite que <Button asChild><a href="/x">Link</a></Button> renderize um <a> com os estilos do Button
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";O padrão asChild com @radix-ui/react-slot é o que permite polimorfismo sem a prop as (que quebra inferência de tipos). Se o consumidor passa asChild, o Button delega todos os seus estilos e props para o elemento filho direto.
Componente Dialog: Radix cuida do foco, Tailwind cuida do visual
// src/components/ui/dialog.tsx
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/cn";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40",
// data-[state=*] vem do Radix: permite animar entrada e saída sem useState
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
export const DialogContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<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-component border border-border-default bg-surface p-6 shadow-lg",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus-visible:outline-2 focus-visible:outline-primary"
aria-label="Fechar"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = "DialogContent";
export const DialogTitle = forwardRef<
HTMLHeadingElement,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-muted-foreground", className)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";O Radix Dialog já faz: trap de foco dentro do modal, retorno de foco ao trigger quando fecha, fechamento via Escape, aria-modal="true" e role="dialog". Você não escreveu uma linha de lógica imperativa para isso.
Uso composto: Dialog com Button
// src/app.tsx — exemplo de consumo
import { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogClose } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function App() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Abrir configurações</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Configurações do projeto</DialogTitle>
<p className="mt-2 text-sm text-muted">
Altere as preferências abaixo e salve.
</p>
<div className="mt-6 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="ghost">Cancelar</Button>
</DialogClose>
<Button>Salvar</Button>
</div>
</DialogContent>
</Dialog>
);
}Repare que DialogTrigger asChild e DialogClose asChild delegam para o Button sem criar wrapper extra no DOM. O HTML resultante é um <button> direto, não <span><button>.
O que NÃO fazer
Anti-pattern 1: estilos inline no primitivo Radix
// ERRADO: estilo acoplado ao primitivo, impossível de sobrescrever pelo consumidor
import * as Dialog from "@radix-ui/react-dialog";
function BadModal() {
return (
<Dialog.Content
style={{
backgroundColor: "white",
borderRadius: 8,
padding: 24,
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{/* ... */}
</Dialog.Content>
);
}O problema: style tem especificidade máxima. Quem consome o componente não consegue mudar backgroundColor via className. O design system perde a capacidade de tematização.
// CORRETO: classes utilitárias via cn(), consumidor sobrescreve com className
<DialogContent className="bg-surface-raised max-w-sm">
{/* O cn() dentro de DialogContent faz twMerge resolver o conflito */}
</DialogContent>Anti-pattern 2: variantes com ternários encadeados
// ERRADO: lógica de variante espalhada no JSX, sem tipagem, sem default
function BadButton({ variant, size, className }: any) {
return (
<button
className={`
${variant === "primary" ? "bg-blue-600 text-white" : ""}
${variant === "outline" ? "border border-gray-300" : ""}
${size === "sm" ? "h-8 px-3" : size === "lg" ? "h-12 px-6" : "h-10 px-4"}
${className}
`}
/>
);
}Três problemas: any em vez de tipo inferido, concatenação de string sem twMerge (classes conflitantes coexistem), e adicionar uma variante nova exige mexer em lógica condicional. CVA resolve os três.
Anti-pattern 3: recriar comportamento que o Radix já entrega
// ERRADO: trap de foco manual, frágil e incompleto
function BadModal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
// Não trapa foco, não retorna foco ao trigger, não bloqueia scroll do body
return <div className="fixed inset-0 z-50">{children}</div>;
}Esse código ignora: trap de foco (Tab navega para fora do modal), retorno de foco ao elemento que abriu, bloqueio de scroll do body, aria-modal, e animação de saída (desmonta instantaneamente). O Dialog do Radix resolve tudo isso com zero configuração.
Organizando o design system para escalar
A estrutura de diretório que funciona para times de 3 a 15 devs:
src/
components/
ui/ # Primitivos do design system (Button, Dialog, Input, Select)
button.tsx
dialog.tsx
input.tsx
index.ts # Re-exporta tudo para import centralizado
patterns/ # Composições de primitivos (ConfirmDialog, SearchInput, DataTable)
confirm-dialog.tsx
lib/
cn.ts # Utilitário de classes
styles/
app.css # Tokens do Tailwind// src/components/ui/index.ts
export { Button, type ButtonProps } from "./button";
export { Dialog, DialogTrigger, DialogContent, DialogTitle, DialogClose } from "./dialog";A separação entre ui/ (primitivos) e patterns/ (composições) evita que o design system vire um monólito de componentes de negócio. Primitivos não importam outros primitivos (exceto cn). Patterns importam primitivos livremente.
Se o design system crescer para um monorepo com pacote publicado, essa estrutura migra para um packages/ui sem refatoração estrutural. Cada componente já é autocontido.
Testando componentes do design system
Radix UI garante acessibilidade em runtime, mas testes automatizados continuam necessários para validar que sua camada de estilo não quebrou o contrato. O teste mais valioso para um design system é o de interação via teclado:
// src/components/ui/__tests__/dialog.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { Dialog, DialogTrigger, DialogContent, DialogTitle } from "../dialog";
import { Button } from "../button";
describe("Dialog", () => {
it("trapa foco e fecha com Escape", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger asChild>
<Button>Abrir</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Título</DialogTitle>
<Button>Ação interna</Button>
</DialogContent>
</Dialog>
);
await user.click(screen.getByText("Abrir"));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// Foco deve retornar ao trigger após fechar
expect(screen.getByText("Abrir")).toHaveFocus();
});
});Quando Radix UI não é a escolha certa
Radix UI é headless: entrega comportamento e acessibilidade, não visual. Se o time precisa de componentes prontos com design aplicado e não tem designer ou tempo para criar tokens, Radix UI adiciona trabalho em vez de reduzir. Nesse cenário, usar shadcn/ui (que é uma camada pré-estilizada sobre Radix + Tailwind) ou Mantine faz mais sentido.
A decisão depende de um fator: o time tem ownership sobre o design visual? Se sim, Radix + Tailwind. Se não, use uma biblioteca opinada e aceite os trade-offs de customização.
Para quem trabalha com APIs fullstack e precisa de tipagem ponta a ponta, a combinação de CVA com TypeScript garante que variantes inválidas são erros de compilação, não bugs em runtime.
FAQ
Qual a diferença entre Radix UI e shadcn/ui?
Radix UI é a biblioteca de primitivos headless (comportamento + acessibilidade, sem estilo). shadcn/ui é uma coleção de componentes pré-estilizados que usa Radix UI por baixo com Tailwind CSS. shadcn/ui não é um pacote npm: você copia os componentes para o seu projeto e tem ownership total do código. Se você seguiu este post, basicamente construiu o mesmo padrão que shadcn/ui usa, com seus próprios tokens.
Preciso instalar todos os pacotes do Radix?
Não. Cada primitivo é um pacote separado (@radix-ui/react-dialog, @radix-ui/react-select, etc.). Instale apenas o que usar. Isso mantém o bundle enxuto: um Dialog completo com acessibilidade adiciona cerca de 10kB gzipped.
CVA é obrigatório para variantes?
Não é obrigatório, mas é a melhor opção disponível para variantes tipadas com Tailwind. A alternativa é escrever um mapa de variantes manual com Record<string, string> e inferir os tipos. CVA faz exatamente isso com menos boilerplate e suporte a defaultVariants e compoundVariants. Se o projeto já usa Stitches ou Vanilla Extract, o padrão de variantes deles substitui CVA.
Como lidar com dark mode nos tokens?
Defina tokens com @media (prefers-color-scheme: dark) ou use a estratégia de classe do Tailwind (dark:bg-surface). No Tailwind v4, você pode declarar variantes de tema diretamente no @theme. O ponto que importa: nomeie tokens por função (surface, primary), não por cor (white, blue-600). Tokens semânticos trocam de valor no dark mode sem que nenhum componente precise mudar.
Posso usar essa abordagem com Next.js App Router?
Sim, com uma ressalva: componentes Radix que gerenciam estado (Dialog, Dropdown, Tooltip) precisam do "use client" directive no arquivo do componente. Os tokens Tailwind e a função cn funcionam em qualquer contexto. Se você estrutura testes para APIs Next.js com Vitest, os testes de componente do design system seguem o mesmo setup.
A posição que defendo
Design systems que tentam abstrair demais acabam recriando o browser. Radix UI acertou ao entregar só comportamento e acessibilidade, delegando o visual para quem consome. Tailwind acertou ao eliminar a indireção entre "nome de classe" e "o que aparece na tela". CVA acertou ao tipar variantes como dados, não como lógica condicional.
Se o seu time tem entre 3 e 15 devs e mantém de 2 a 5 produtos com identidade visual compartilhada, essa stack resolve o problema com o mínimo de abstração. Menos que isso, copie componentes do shadcn/ui e siga em frente. Mais que isso, considere um pacote publicado no registry interno com CI/CD automatizado e versionamento semântico.
O trabalho real de um design system não é escolher a biblioteca. É manter o contrato de API estável enquanto o visual evolui. Radix + Tailwind + CVA tornam esse contrato explícito, tipado e auditável. Isso é o que importa.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


