Pular para o conteúdo
~/.primo-academy.sh
☰ Aulas · Storybook & Design Systems: tokens, primitives, versionamento · 0/8
Recomendado: essencial

Composicao: compound components, slot patterns, headless primitives

7 min de leitura

fonte

Você tem o <Button> como primitive. Mas e o <Card> que tem Header, Body, Footer? O <Tabs> que tem List, Trigger, Content? O <FormField> que é Label + Input + HelpText + ErrorMessage? Esses não cabem em um único componente - são composições.

A decisão de design de uma boa DS é: quando um componente é "uma coisa só" (primitive) e quando é "várias coisas que vivem juntas" (pattern). E pra cada pattern, como implementar de forma que o consumidor use sem saber dos detalhes internos.

Este nó cobre os 3 padrões principais: compound components (a forma "clássica" em React), slot patterns (a forma flexível), e headless primitives (a forma "lógica sem estilo").

O essencial 🟢

O problema: API inchada. A tentação inicial de "componente com tudo" é adicionar props:

// RUIM: o Button virou um canivete suíço
<Button
  label="Salvar"
  iconLeft={<SaveIcon />}
  iconRight={<ArrowIcon />}
  variant="primary"
  size="md"
  loading={isLoading}
  badge={<Badge>3</Badge>}
  tooltip="Salvar formulário"
  // ... mais 20 props
/>

Funciona? Sim. Mas a Button virou 200 linhas de código, o consumidor tem que memorizar 20 props, e cada combinação de prop é um estado a testar. O componente faz coisa demais.

A solução é compor: ter vários componentes pequenos que o consumidor combina pra ter o resultado final:

// BOM: cada componente faz uma coisa
<Button variant="primary" loading={isLoading}>
  <SaveIcon />
  Salvar
</Button>

// Ou com compound components:
<Button variant="primary">
  <Button.Icon><SaveIcon /></Button.Icon>
  <Button.Label>Salvar</Button.Label>
</Button>

Compound components - o padrão clássico. O padrão é ter um componente "pai" que expõe sub-componentes como propriedades estáticas:

import { Card } from "./Card";
import { CardHeader } from "./CardHeader";
import { CardBody } from "./CardBody";
import { CardFooter } from "./CardFooter";

// Atribui sub-componentes
Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;

export { Card };
// Uso
<Card>
  <Card.Header>Título</Card.Header>
  <Card.Body>Conteúdo do card aqui.</Card.Body>
  <Card.Footer>
    <Button>OK</Button>
  </Card.Footer>
</Card>

Vantagens:

  • API legível - <Card.Header> é auto-explicativo.
  • Composable - você usa o que precisa. Sem Footer? Tudo bem.
  • Compartilha state via Context (visto abaixo).
  • Type-safe com TS.

Implementação com Context. O compound component compartilha state entre pai e filhos via React Context:

import { createContext, useContext, ReactNode } from "react";

type CardContextValue = {
  variant: "primary" | "secondary";
};

const CardContext = createContext<CardContextValue | null>(null);

function useCardContext() {
  const ctx = useContext(CardContext);
  if (!ctx) {
    throw new Error("Card.Header deve estar dentro de <Card>");
  }
  return ctx;
}

function Card({
  variant = "primary",
  children,
}: {
  variant?: "primary" | "secondary";
  children: ReactNode;
}) {
  return (
    <CardContext.Provider value={{ variant }}>
      <div className={`card card-${variant}`}>{children}</div>
    </CardContext.Provider>
  );
}

function CardHeader({ children }: { children: ReactNode }) {
  // Tem acesso a variant via context
  const { variant } = useCardContext();
  return <div className={`card-header card-header-${variant}`}>{children}</div>;
}

function CardBody({ children }: { children: ReactNode }) {
  return <div className="card-body">{children}</div>;
}

function CardFooter({ children }: { children: ReactNode }) {
  return <div className="card-footer">{children}</div>;
}

Card.Header = CardHeader;
Card.Body = CardBody;
Card.Footer = CardFooter;

O useCardContext joga erro se o sub-componente está fora do <Card> - pega bug em dev.

useId e acessibilidade. Componentes compostos (Tabs, Accordion, RadioGroup) têm relação de pai-filho semântica que precisa ser refletida em ARIA. useId do React gera ID único consistente entre pai e filho:

import { useId } from "react";

function Tabs({ children }: { children: ReactNode }) {
  const baseId = useId();
  return (
    <TabsContext.Provider value={{ baseId }}>
      {children}
    </TabsContext.Provider>
  );
}

function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
  const { baseId } = useTabsContext();
  return (
    <button
      id={`${baseId}-trigger-${value}`}
      aria-controls={`${baseId}-panel-${value}`}
    >
      {children}
    </button>
  );
}

useId resolve o problema de "como ligar aria-controls e id entre pai e filho" sem conflito de nomes.

Slot pattern - a forma flexível. Quando "compound" é restritivo demais, o slot (reservado, "lugar pra colocar X") é mais flexível:

// AlertDialog com slots
<AlertDialog>
  <AlertDialog.Icon><WarningIcon /></AlertDialog.Icon>
  <AlertDialog.Title>Tem certeza?</AlertDialog.Title>
  <AlertDialog.Description>
    Essa ação não pode ser desfeita.
  </AlertDialog.Description>
  <AlertDialog.Actions>
    <Button>Cancelar</Button>
    <Button variant="danger">Deletar</Button>
  </AlertDialog.Actions>
</AlertDialog>

A diferença pro compound: slots têm nomes semânticos (Icon, Title, Description) mas não têm implementação fixa - você coloca o que quiser dentro. Compound tem componente pronto (Card.Header é uma div com classe).

Use slot quando o conteúdo é variável (qualquer elemento, não uma div específica). Use compound quando o sub-componente é um "pedaço" bem definido do pai (sempre uma div com classe card-header).

Headless primitives - lógica sem estilo. O padrão moderno (Radix UI, Headless UI, React Aria) separa lógica (acessibilidade, teclado, foco, ARIA) de estilo (CSS). Você consome a lógica e estiliza à sua maneira:

// Com Radix UI (lógica)
import * as Dialog from "@radix-ui/react-dialog";

<Dialog.Root>
  <Dialog.Trigger asChild>
    <Button>Abrir</Button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="modal-overlay" />
    <Dialog.Content className="modal-content">
      <Dialog.Title>Editar perfil</Dialog.Title>
      <Dialog.Description>Faça as mudanças e salve.</Dialog.Description>
      {/* conteúdo customizado */}
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

O que Radix te dá de graça:

  • Acessibilidade (foco preso no modal, ESC fecha, ARIA correto).
  • Keyboard navigation (Tab, Shift+Tab, Enter, Space).
  • Portal (renderiza fora da hierarquia DOM pra não ter z-index issue).
  • Focus trap (Tab não sai do modal).
  • Animations (data-state pra CSS animations).

Você estiliza com Tailwind, CSS modules, ou qualquer outra coisa. Não há estilo default do Radix - é 100% customizável.

Quando usar cada padrão. Resumo:

Quatro padroes de composicao em ordem crescente de complexidade: primitive (uma coisa), compound (sub-partes fixas via Context), slot (sub-partes variaveis via children nomeado), headless (lib externa com logica sem estilo).
  • Primitive - uma coisa só. Use quando não tem sub-partes (<Button>, <Input>, <Badge>).
  • Compound - sub-partes fixas, implementadas como sub-componentes com Context. Use quando tem 2-5 sub-partes bem definidas e você quer forçar o consumidor a usar as certas (<Card>, <Tabs>, <Accordion>).
  • Slot - sub-partes com nome semântico mas conteúdo variável. Use quando tem estrutura esperada mas cada parte é custom (<Modal>, <AlertDialog>, <EmptyState>).
  • Headless - lógica complexa, estilo customizado. Use quando a lógica de acessibilidade/teclado/foco é grande demais pra você implementar (<Dialog>, <Combobox>, <Popover>).

A regra prática: comece primitive, vire compound quando precisa de sub-partes, vire slot quando precisa de variação, vá pra headless quando a lógica domina.

Aprofundamento 🟡

As 3 implementações de compound component. Tem 3 formas de implementar o pattern:

  • React.Children + clone (legacy, 2015) - itera sobre children, injeta props via cloneElement. Funciona, mas type-safety fraca e prop drilling implícito.
  • Context (recomendado) - o que vimos acima. Type-safe com useContext, claro, e escala bem.
  • as prop + composition (React 18+, Radix-style) - passa props de pai pra filho via "asChild" pattern. Mais flexível, mas mais mágico.

Context vs asChild: Context é a forma tradicional e didática. asChild é a forma moderna, mais flexível, mas tem curva de aprendizado:

// Context (visto acima)
<Dialog.Trigger asChild>
  <Button>Abrir</Button>
</Dialog.Trigger>

// Equivalente sem asChild (mais verboso)
<Dialog.Trigger>
  <Button onClick={(e) => {
    // delegar pro Dialog.Trigger
    e.preventDefault();
  }}>
    Abrir
  </Button>
</Dialog.Trigger>

asChild faz a fusão automática - o <Button> recebe os handlers do <Dialog.Trigger> e a delegação é invisível. Em DS grande, vale o investimento. Em DS pequeno, Context é suficiente.

Compound components com TypeScript discriminated unions. Pra ter type-safety forte em props que dependem de variant:

type CardProps =
  | { variant: "primary"; highlight?: boolean }
  | { variant: "secondary"; dashed?: boolean };

function Card(props: CardProps & { children: ReactNode }) {
  // TS sabe que `highlight` existe se variant === "primary"
  // TS sabe que `dashed` existe se variant === "secondary"
  return <div className={`card-${props.variant}`}>{props.children}</div>;
}

O pattern discrimina o tipo baseado no variant - mesma técnica vista na nextjs (RSC variants).

forwardRef em compound components. Pra componentes compostos que precisam de ref no elemento raiz (comum em UI lib pra focar):

import { forwardRef } from "react";

const Card = forwardRef<HTMLDivElement, CardProps>(
  ({ variant, children }, ref) => (
    <CardContext.Provider value={{ variant }}>
      <div ref={ref} className={`card card-${variant}`}>
        {children}
      </div>
    </CardContext.Provider>
  )
);
Card.displayName = "Card"; // útil pra devtools

Em React 19+, ref é prop normal (não precisa de forwardRef). Vale conhecer o pattern pro legacy code.

Server Components e compound components. A trilha é focada em Client Components ("use client" em todos). Se o DS precisa funcionar em RSC (Next 15+ App Router), os componentes compostos precisam ter o "use client" no topo do arquivo. A composição (compound, slot) ainda funciona

  • Context é client-side, então tudo vira Client. Em 2026, DSs misturam: componentes de display (Card, Button) podem ser RSC se não usam interatividade, mas compostos (Tabs, Dialog) são Client.

Storybook + compound components. Em story de compound, o render é a melhor abordagem:

export const WithHeader: Story = {
  render: () => (
    <Card>
      <Card.Header>Título</Card.Header>
      <Card.Body>Conteúdo.</Card.Body>
    </Card>
  ),
};

export const WithFooter: Story = {
  render: () => (
    <Card>
      <Card.Header>Título</Card.Header>
      <Card.Body>Conteúdo.</Card.Body>
      <Card.Footer>
        <Button>OK</Button>
      </Card.Footer>
    </Card>
  ),
};

Cada combinação comum vira uma story. Não escreva uma story que testa "tudo junto" - o consumidor quer ver "como fica o Card sem footer" e "com header e footer" separados.

Pra quem quer ir além 🔴

A história do compound component. O pattern apareceu em React 0.x (2013-2014), quando a comunidade descobriu que "componente com props demais" era um anti-pattern. Ryan Florence (popularizou com react-router) e Kent C. Dodds (materialized o pattern) foram os maiores disseminadores. Em 2015-2017, era o pattern padrão em libs como Material UI, Ant Design. Em 2018+, Radix UI popularizou o asChild como evolução. Em 2026, o padrão é claro: Context pra lógica compartilhada, asChild pra delegação de props, slot pra conteúdo variável.

asChild vs Context: trade-off. Resumo:

  • Context - didático, fácil de debug, padrão de "React idiomático". Limitação: precisa de Context Provider, o que adiciona uma camada de abstração.
  • asChild - sem Context, mais flexível, mais mágico. Limitação: menos óbvio pra quem lê o código pela primeira vez ("por que asChild faz o Button virar o trigger?").

Em DS novo em 2026, use Context (didático, type-safe). Migre pra asChild se o DS crescer e o Context virar boilerplate.

Por que headless primitives venceram em 2026. O movimento headless (Radix, Headless UI, React Aria) tomou o espaço do "lib de UI pronta" (MUI, Ant) por uma razão simples: estilo customizado. Toda empresa quer ser diferente, e lib de UI pronta força estilo padrão. Com headless, você pega a parte difícil (acessibilidade, teclado, ARIA, focus trap) e estiliza à sua maneira. Em 2026, é o caminho padrão pra DS novo. MUI, Chakra, Mantine estão adicionando "headless mode" pra competir.

Custo de oportunidade de cada padrão. Em geral:

  • Primitive - 0 overhead, sem complexidade. Todo DS começa aqui.
  • Compound - 1-2 dias de setup. Vale pra 80% dos casos.
  • Slot - 2-3 dias. Vale pra 20% dos casos (Modal, Dialog, EmptyState).
  • Headless - depende de lib externa (Radix, React Aria). Adicionar Radix = +~50KB no bundle. Vale pra 5% dos casos (componentes com lógica complexa de acessibilidade que você não quer reimplementar).

A regra: use o padrão mais simples que atenda. Primitive → Compound → Slot → Headless, nessa ordem, conforme a necessidade aparece.

Leitura recomendada:

Dica: o erro mais comum no começo é tentar usar o mesmo padrão pra tudo. O time aprende compound e quer fazer todo componente compound. A regra é: primitive quando não tem sub-partes, compound quando tem 2-5 sub-partes bem definidas. <Button> é primitive. <Card> é compound. <Modal> é slot. <Combobox> é headless. Cada um na sua.

No próximo nó, vamos falar de documentação: Storybook MDX, autodocs, e como escrever docs que devs realmente leem (em vez de só gerar).

// Quiz

Quando usar headless primitives (Radix, React Aria) em vez de construir o componente do zero?

Escolha uma alternativa

// recursos

// avaliação da trilha

—
ainda sem avaliações