Ir para o conteúdo
React

Como Criar um Design System com Radix UI e Tailwind CSS

Marcos Soares
Atualizado em 
12 minutos de leitura
Ilustracao 3D de blocos modulares de vidro fosco e metal encaixados representando design system com Radix UI e Tailwind CSS
Ouça este artigo
0:00Como 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ê

ResponsabilidadeRadix UITailwind CSSSeu código
Semântica HTML e ARIASimNãoNão
Gerenciamento de foco (trap, restore)SimNãoNão
Animações de entrada/saídaParcial (data attributes)Sim (classes utilitárias)Conecta os dois
Tokens visuais (cor, espaçamento, tipografia)NãoSim (theme config)Define os tokens
API de variantes tipadaNãoNãoSim (com cva ou equivalente)
Composição de componentesSim (compound components)Não se aplicaEncapsula 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

Bash
# 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
TypeScript
// 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.

CSS
/* 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

TSX
// 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

TSX
// 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

TSX
// 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

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

TSX
// 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

TSX
// 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

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

Text
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
TypeScript
// 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:

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

Marcos Soares

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.