Como Construir uma Biblioteca de Design System Robusta e Adaptável

Um guia técnico sobre arquitetura de Design Systems — dos design tokens a componentes compostos, escalabilidade, governança e estratégias para múltiplos produtos.

Manter consistência visual e comportamental em uma aplicação grande é um dos desafios mais subestimados do desenvolvimento frontend. À medida que o produto cresce, surgem problemas clássicos: botões com sete variações sutilmente diferentes, formulários que se comportam de forma inconsistente entre módulos, temas de cores hardcoded em centenas de arquivos. A solução estruturada para esses problemas é o Design System — e neste artigo vamos explorar como construir um que seja robusto, adaptável e capaz de escalar junto com o produto.

O que é um Design System (e o que não é)

Um equívoco comum é confundir Design System com UI Kit. Um UI Kit é uma coleção de componentes visuais — botões, inputs, modais. Útil, mas superficial.

Um Design System é uma camada de abstração muito mais rica: engloba princípios de design, tokens semânticos, padrões de interação, documentação, governança e ferramentas de desenvolvimento. É a linguagem compartilhada entre design e engenharia que permite equipes diferentes construir experiências coesas sem necessidade de comunicação constante.

A diferença prática: um UI Kit resolve “como esse botão deve parecer”. Um Design System responde “por que esse botão existe, quando usá-lo, como ele se comporta em contextos diferentes, e como ele evolui junto com o produto.” Um bom Design System tem opiniões — e essas opiniões reduzem a carga cognitiva dos times.

Problemas Comuns em Aplicações Grandes

Antes de sair construindo componentes, vale entender os problemas concretos que um Design System precisa resolver:

  • Divergência visual: desenvolvedores hardcodam valores (#6366f1, 12px, 0.875rem) sem referência a tokens semânticos, e cada equipe acaba com variações sutis dos mesmos elementos
  • Duplicação de lógica: a mesma lógica de debounce ou de gestão de foco de dropdown implementada cinco vezes, cada uma com bugs diferentes
  • Refatoração custosa: mudar a paleta de cores primária requer busca e substituição em centenas de arquivos, com risco de regressão
  • Inconsistência de acessibilidade: componentes sem aria-label adequado, sem gestão de foco, sem suporte a teclado — cada time resolve (ou não resolve) por conta própria
  • Silos de conhecimento: o time A não sabe o que o time B já construiu, gerando retrabalho e fragmentação

O Design System é a resposta sistêmica para todos esses problemas. Mas só funciona se for construído com a arquitetura certa.

Arquitetura em Camadas

A fundação de um Design System bem construído é uma arquitetura clara em camadas, onde cada nível depende apenas do nível abaixo — nunca do produto em si:

graph TB
  A["🎨 Primitivos — Cores, Tipografia, Espaçamento bruto"] --> B["🔤 Tokens Semânticos — Significado contextual"]
  B --> C["🧩 Componentes Base — Button, Input, Text, Icon"]
  C --> D["⚙️ Compostos — Form, DataTable, Modal, Tabs"]
  D --> E["📄 Padrões — Dashboard Layout, Auth Flow, Empty States"]
  E --> F["🚀 Produto Final"]

Essa separação garante portabilidade (os componentes base podem ser usados em qualquer produto), testabilidade independente (cada camada pode ser testada isoladamente) e evolução incremental (você pode atualizar tokens sem tocar em componentes, ou componentes sem tocar em padrões de página).

Design Tokens: A Fundação Real

Tokens são a tradução de decisões de design em variáveis nomeadas e compartilháveis entre plataformas. Eles operam em dois níveis complementares:

Tokens Primitivos

São a escala bruta de valores — a paleta completa de cores, a escala tipográfica, o sistema de espaçamento. Sem semântica, apenas valores:

:root {
  /* Paleta de cores bruta */
  --color-indigo-400: #818cf8;
  --color-indigo-500: #6366f1;
  --color-indigo-600: #4f46e5;
  --color-red-400:    #f87171;
  --color-green-400:  #34d399;

  /* Escala tipográfica modular (razão 1.25) */
  --font-size-xs:  0.64rem;
  --font-size-sm:  0.8rem;
  --font-size-md:  1rem;
  --font-size-lg:  1.25rem;
  --font-size-xl:  1.563rem;
  --font-size-2xl: 1.953rem;

  /* Escala de espaçamento (base 4px) */
  --space-1: 0.25rem;   /*  4px */
  --space-2: 0.5rem;    /*  8px */
  --space-3: 0.75rem;   /* 12px */
  --space-4: 1rem;      /* 16px */
  --space-6: 1.5rem;    /* 24px */
  --space-8: 2rem;      /* 32px */
  --space-12: 3rem;     /* 48px */
}

Tokens Semânticos

Mapeiam os primitivos para intenções específicas. É aqui que o Design System ganho real — você nomeia o que o token representa, não qual é o valor:

:root {
  /* Cores com significado contextual */
  --color-brand-primary:    var(--color-indigo-500);
  --color-brand-hover:      var(--color-indigo-400);
  --color-brand-active:     var(--color-indigo-600);

  --color-bg-default:       #07070f;
  --color-bg-surface:       #0d0d1c;
  --color-bg-surface-2:     #111128;

  --color-text-default:     #eaeaf6;
  --color-text-muted:       #6b6b8e;
  --color-text-disabled:    #3a3a58;

  --color-feedback-error:   var(--color-red-400);
  --color-feedback-success: var(--color-green-400);

  /* Bordas */
  --border-default:  rgba(129, 140, 248, 0.08);
  --border-hover:    rgba(129, 140, 248, 0.22);
  --border-focus:    var(--color-brand-primary);

  /* Elevação */
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
  --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
}

A vantagem dos tokens semânticos é que você pode mudar toda a paleta tocando apenas os primitivos, ou criar temas completos remapeando apenas os semânticos — sem tocar em um único componente.

Theming: Dark Mode e White Label

Com tokens semânticos bem definidos, implementar temas é direto:

/* Tema claro */
[data-theme="light"] {
  --color-bg-default:   #f8f8ff;
  --color-bg-surface:   #ffffff;
  --color-text-default: #1a1a2e;
  --color-text-muted:   #4a4a6a;
}

/* White label: cliente com marca própria */
[data-theme="acme"] {
  --color-brand-primary: #0ea5e9;
  --color-brand-hover:   #38bdf8;
}

No React, um ThemeProvider simples gerencia a aplicação do tema:

import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'dark' | 'light' | 'acme';

interface ThemeContextValue {
  theme: Theme;
  setTheme: (t: Theme) => void;
}

const ThemeContext = createContext<ThemeContextValue>({
  theme: 'dark',
  setTheme: () => {},
});

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem('theme') as Theme | null) ?? 'dark'
  );

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);

Para respeitar a preferência do sistema operacional, adicione antes do useState:

const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const stored = localStorage.getItem('theme') as Theme | null;
const initial = stored ?? (prefersDark ? 'dark' : 'light');

Componentes Compostos e Atomic Design

A metodologia Atomic Design define cinco níveis: Átomos → Moléculas → Organismos → Templates → Páginas. Na prática, o que funciona melhor para teams de produto são três camadas operacionais:

  1. Primitivos: elementos sem estado, puramente visuais (Text, Icon, Divider, Skeleton)
  2. Componentes base: elementos com comportamento encapsulado (Button, Input, Checkbox, Badge, Tooltip)
  3. Compostos: composição de bases com lógica coordenada (Form, Modal, Tabs, DataTable, Combobox)

O padrão Compound Component é especialmente poderoso para os compostos, pois permite APIs expressivas sem prop drilling excessivo:

import { createContext, useContext, useRef, useState } from 'react';

interface SelectContextValue {
  value: string;
  onChange: (v: string) => void;
  open: boolean;
  setOpen: (o: boolean) => void;
}

const SelectContext = createContext<SelectContextValue | null>(null);
const useSelect = () => {
  const ctx = useContext(SelectContext);
  if (!ctx) throw new Error('useSelect deve ser usado dentro de <Select>');
  return ctx;
};

function Select({
  value,
  onChange,
  children,
}: {
  value: string;
  onChange: (v: string) => void;
  children: React.ReactNode;
}) {
  const [open, setOpen] = useState(false);
  return (
    <SelectContext.Provider value={{ value, onChange, open, setOpen }}>
      <div className="select-root" role="combobox" aria-expanded={open}>
        {children}
      </div>
    </SelectContext.Provider>
  );
}

Select.Trigger = function SelectTrigger({
  children,
}: {
  children: React.ReactNode;
}) {
  const { open, setOpen } = useSelect();
  return (
    <button
      type="button"
      aria-haspopup="listbox"
      onClick={() => setOpen(!open)}
      className="select-trigger"
    >
      {children}
      <span className="select-chevron" aria-hidden="true">
        {open ? '▲' : '▼'}
      </span>
    </button>
  );
};

Select.Options = function SelectOptions({
  children,
}: {
  children: React.ReactNode;
}) {
  const { open } = useSelect();
  if (!open) return null;
  return (
    <ul role="listbox" className="select-options">
      {children}
    </ul>
  );
};

Select.Option = function SelectOption({
  value,
  children,
}: {
  value: string;
  children: React.ReactNode;
}) {
  const ctx = useSelect();
  const selected = ctx.value === value;
  return (
    <li
      role="option"
      aria-selected={selected}
      className={`select-option ${selected ? 'select-option--selected' : ''}`}
      onClick={() => {
        ctx.onChange(value);
        ctx.setOpen(false);
      }}
    >
      {children}
    </li>
  );
};

// Uso:
// <Select value={lang} onChange={setLang}>
//   <Select.Trigger>{lang}</Select.Trigger>
//   <Select.Options>
//     <Select.Option value="pt">Português</Select.Option>
//     <Select.Option value="en">English</Select.Option>
//   </Select.Options>
// </Select>

Esta API é muito mais ergonômica do que <Select options={[...]} renderOption={...} /> e permite customizações profundas sem modificar o componente base.

Escalabilidade e Governança

Um Design System cresce ou morre pela sua governança. Sem processo, vira uma gaveta de componentes inconsistentes que ninguém confia. Algumas práticas essenciais:

Versionamento Semântico Estrito

Siga SemVer sem exceções:

  • MAJOR (2.0.0): breaking changes em APIs públicas de componentes
  • MINOR (1.3.0): novos componentes ou props retrocompatíveis
  • PATCH (1.2.1): correções de bugs e ajustes visuais menores

Com isso, times podem adotar ^1.x.x no package.json e receber novas funcionalidades automaticamente sem risco de quebra.

RFC para Novos Componentes

Antes de implementar, exija uma proposta estruturada:

  1. Problema: qual caso de uso não está coberto
  2. Alternativas: o que foi considerado antes de criar algo novo
  3. API proposta: interface TypeScript do componente
  4. Decisões de acessibilidade: quais padrões ARIA serão seguidos
  5. Aprovação: design + engenharia + acessibilidade

Componente sem RFC aprovada não entra no Design System. Essa regra é inegociável.

Monorepo, Storybook e Testes Visuais

A estrutura de monorepo mais eficiente para times que desenvolvem um Design System:

packages/
├── tokens/       # @ds/tokens — CSS vars, JS constants, design tokens
├── icons/        # @ds/icons — componentes SVG tipados
├── core/         # @ds/core — Button, Input, Text, Badge...
├── composed/     # @ds/composed — Form, Modal, DataTable...
└── docs/         # Storybook + documentação interativa
apps/
├── web/          # Produto principal
└── mobile/       # React Native

Com Turborepo, você garante builds incrementais com cache compartilhado — essencial quando o monorepo cresce para dezenas de pacotes.

O Storybook é indispensável não apenas para documentação, mas também para:

  • Desenvolvimento isolado sem precisar rodar o app completo
  • Stories como especificação executável dos estados possíveis
  • Testes de acessibilidade via addon @storybook/addon-a11y

Para testes visuais, Chromatic ou Percy capturam screenshots de cada story e detectam regressões automaticamente em cada PR — impedindo que uma mudança global de tokens quebre variações em contextos inesperados.

Acessibilidade por Padrão

Acessibilidade não é feature — é parte da definição de “pronto” para qualquer componente. Cada elemento do Design System deve:

  • Ter suporte completo a teclado: Tab, Shift+Tab, Enter, Escape, setas
  • Usar roles ARIA corretos: role="dialog", role="listbox", aria-expanded, aria-live
  • Gerenciar foco corretamente: ao abrir modal, foco vai para o primeiro elemento interativo; ao fechar, retorna ao trigger
  • Respeitar prefers-reduced-motion para animações

Um hook de useFocusTrap é fundamental para qualquer componente de overlay:

import { useEffect, useRef } from 'react';

export function useFocusTrap(active: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!active || !containerRef.current) return;

    const FOCUSABLE =
      'button:not([disabled]), [href], input:not([disabled]), ' +
      'select:not([disabled]), textarea:not([disabled]), ' +
      '[tabindex]:not([tabindex="-1"])';

    const focusable = Array.from(
      containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    const trapFocus = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last?.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first?.focus();
      }
    };

    document.addEventListener('keydown', trapFocus);
    first?.focus();

    return () => document.removeEventListener('keydown', trapFocus);
  }, [active]);

  return containerRef;
}

// Uso em um Modal:
// const ref = useFocusTrap(isOpen);
// return isOpen ? <div ref={ref} role="dialog" aria-modal="true">...</div> : null;

Estratégias para Múltiplos Produtos

Quando o Design System serve múltiplos produtos — web, mobile, dashboard administrativo, portal do cliente — a arquitetura precisa de mais granularidade:

graph LR
  T["@ds/tokens<br/>Primitivos + Semânticos"] --> CORE["@ds/core<br/>Componentes Base"]
  CORE --> WEB["@ds/web<br/>Componentes Web"]
  CORE --> MOB["@ds/mobile<br/>React Native"]
  WEB --> P1["Produto A<br/>tema: brand-a"]
  WEB --> P2["Produto B<br/>tema: brand-b"]
  MOB --> APP["App iOS + Android"]

Cada produto importa os mesmos componentes base mas aplica seus próprios tokens de extensão — sem fork, sem duplicação. O Produto A pode ter --color-brand-primary: #0ea5e9 enquanto o Produto B usa #f59e0b, ambos compartilhando os mesmos componentes core.

Para mobile, a estratégia com React Native é usar os tokens em StyleSheet ao invés de CSS vars, mas mantendo os mesmos nomes semânticos — garantindo que designers possam trabalhar com um único arquivo de tokens que alimenta todas as plataformas.

Métricas de Adoção

Um Design System só tem valor real se for efetivamente adotado. Meça continuamente:

  • Coverage: percentual de elementos de UI no produto que vêm do DS vs. construídos localmente
  • Versão ativa: quantas versões diferentes do DS coexistem em produção (idealmente: apenas a mais recente)
  • Velocity: tempo médio para implementar uma feature usando o DS vs. do zero
  • Bugs de inconsistência: issues relacionados a UI inconsistente abertas no tracker — devem tender a zero
  • NPS interno: pesquisa trimestral com os times consumidores sobre satisfação com o DS

Ferramentas como react-scanner ou análise estática com AST podem automatizar o mapeamento de coverage, apontando exatamente quais componentes locais deveriam ser migrados para o DS.

Conclusão

Construir um Design System robusto é um investimento de longo prazo que nunca termina — mas cujo retorno composto é exponencial. Com o tempo, a velocidade de desenvolvimento aumenta, a consistência melhora e o custo de manutenção cai drasticamente.

Os cinco princípios que guiam uma implementação bem-sucedida:

  1. Tokens primeiro: toda decisão visual começa como token semântico, nunca hardcoded
  2. API pública estável: componentes mudam internamente, mas suas interfaces TypeScript só quebram em versões major
  3. Acessibilidade como requisito: não é opcional, é parte da definição de “pronto”
  4. Documentação viva: Storybook atualizado junto com o código, nunca depois
  5. Governança clara: RFC, code review rigoroso e changelog cuidadoso mantêm o Design System como ativo, não passivo

Um Design System é tão bom quanto a cultura de engenharia que o sustenta. Componentes são a parte fácil — o difícil é manter a consistência, o processo e a disciplina ao longo do tempo. Mas quando funciona, é um dos investimentos mais impactantes que um time de produto pode fazer.