Headless Components em React: Lógica sem Apresentação com Radix UI na Prática Já leu

O Que São Headless Components? Um headless component é um componente React que encapsula toda a lógica, estado e comportamento de um elemento de interface, mas deliberadamente não fornece nenhuma marcação HTML ou estilo visual. Essa separação entre a inteligência (lógica) e a apresentação (visual) permite que você reutilize a mesma lógica complexa em múltiplos contextos visuais diferentes, sem duplicar código. A ideia central é inverter o modelo tradicional de desenvolvimento de componentes. Em vez de criar um componente monolítico que traz consigo HTML, CSS e JavaScript acoplados, você trabalha com dois planos distintos: o componente sem cabeça, que gerencia tudo relacionado ao comportamento (acessibilidade, keyboard navigation, estado, focus management), e o componente apresentacional, que você constrói para refletir a identidade visual do seu projeto. Isso não é uma abstração teórica — é um padrão pragmático que bibliotecas como Radix UI exploram para resolver problemas reais de acessibilidade e reutilização. Introdução ao Radix UI O que é Radix UI e

O Que São Headless Components?

Um headless component é um componente React que encapsula toda a lógica, estado e comportamento de um elemento de interface, mas deliberadamente não fornece nenhuma marcação HTML ou estilo visual. Essa separação entre a inteligência (lógica) e a apresentação (visual) permite que você reutilize a mesma lógica complexa em múltiplos contextos visuais diferentes, sem duplicar código.

A ideia central é inverter o modelo tradicional de desenvolvimento de componentes. Em vez de criar um componente monolítico que traz consigo HTML, CSS e JavaScript acoplados, você trabalha com dois planos distintos: o componente sem cabeça, que gerencia tudo relacionado ao comportamento (acessibilidade, keyboard navigation, estado, focus management), e o componente apresentacional, que você constrói para refletir a identidade visual do seu projeto. Isso não é uma abstração teórica — é um padrão pragmático que bibliotecas como Radix UI exploram para resolver problemas reais de acessibilidade e reutilização.

Introdução ao Radix UI

O que é Radix UI e por que escolher?

Radix UI é uma biblioteca de componentes headless de baixo nível, mantida pela comunidade React, que oferece primitivos acessíveis prontos para uso. Ela não impõe nenhuma opinião visual — você controla 100% do CSS e da marcação — mas fornece toda a engenharia pesada: ARIA attributes corretos, keyboard interactions (setas, Enter, Escape), focus trap em modais, gerenciamento de estado complexo e muito mais.

Escolher Radix UI significa escolher acessibilidade de primeira classe, sem a rigidez visual de bibliotecas opintonadas como Material-UI ou Chakra UI. Você obtém um contrato explícito: a lógica é controlada por Radix, a apresentação é sua responsabilidade. Isso resulta em designs únicos, leve como ar, sem CSS bloated.

Instalação e Configuração Inicial

Para começar, você precisa apenas instalar o pacote do componente que vai usar. Vamos iniciar com um simples exemplo usando @radix-ui/react-dialog:

npm install @radix-ui/react-dialog

Se você estiver usando TypeScript (recomendado), os tipos já vêm inclusos. A configuração é mínima — não há providers globais ou temas para configurar. Você importa, usa, e controla tudo o mais.

Criando Seu Primeiro Headless Component com Radix UI

Exemplo Prático: Dialog (Modal) Personalizado

Vamos construir um diálogo modal do zero usando Radix UI. Este exemplo mostrará como o Radix fornece a lógica, e você fornece a apresentação:

import React, { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import styles from './CustomDialog.module.css';

export function CustomDialog() {
  const [open, setOpen] = useState(false);

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      {/* O trigger é apenas um botão. Radix não impõe nada aqui */}
      <Dialog.Trigger asChild>
        <button className={styles.triggerButton}>
          Abrir Diálogo
        </button>
      </Dialog.Trigger>

      {/* Portal renderiza o conteúdo em um div fora da hierarquia */}
      <Dialog.Portal>
        {/* Overlay é o fundo escuro. Você controla o estilo completamente */}
        <Dialog.Overlay className={styles.overlay} />

        {/* Content é o diálogo em si. Radix gerencia focus, keyboard escape, etc */}
        <Dialog.Content className={styles.content}>
          <div className={styles.header}>
            <Dialog.Title className={styles.title}>
              Confirme sua ação
            </Dialog.Title>
            <Dialog.Close asChild>
              <button 
                className={styles.closeButton}
                aria-label="Fechar diálogo"
              >
                ✕
              </button>
            </Dialog.Close>
          </div>

          <Dialog.Description className={styles.description}>
            Esta é uma descrição do diálogo. Radix já adicionou os atributos
            ARIA necessários automaticamente.
          </Dialog.Description>

          <div className={styles.footer}>
            <Dialog.Close asChild>
              <button className={styles.buttonSecondary}>
                Cancelar
              </button>
            </Dialog.Close>
            <button className={styles.buttonPrimary}>
              Confirmar
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Agora o CSS do seu módulo, que demonstra a liberdade visual que você tem:

/* CustomDialog.module.css */

.triggerButton {
  padding: 10px 16px;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 500;
  font-size: 14px;
}

.triggerButton:hover {
  background: #0052a3;
}

.overlay {
  background: rgba(0, 0, 0, 0.5);
  position: fixed;
  inset: 0;
  animation: fadeIn 150ms ease-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.content {
  background: white;
  border-radius: 12px;
  box-shadow: 0 20px 25px rgba(0, 0, 0, 0.15);
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 90vw;
  max-width: 500px;
  padding: 24px;
  animation: slideIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translate(-50%, -48%);
  }
  to {
    opacity: 1;
    transform: translate(-50%, -50%);
  }
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.title {
  margin: 0;
  font-size: 18px;
  font-weight: 600;
  color: #1a1a1a;
}

.closeButton {
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
  color: #666;
  padding: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.closeButton:hover {
  color: #000;
}

.description {
  margin: 16px 0;
  color: #555;
  line-height: 1.5;
  font-size: 14px;
}

.footer {
  display: flex;
  gap: 12px;
  justify-content: flex-end;
  margin-top: 20px;
}

.buttonSecondary {
  padding: 8px 16px;
  background: #f0f0f0;
  color: #1a1a1a;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 500;
  font-size: 14px;
}

.buttonSecondary:hover {
  background: #e0e0e0;
}

.buttonPrimary {
  padding: 8px 16px;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 500;
  font-size: 14px;
}

.buttonPrimary:hover {
  background: #0052a3;
}

Aqui está o ponto crucial: Radix UI forneceu toda a orquestração — fechar quando você pressiona Escape, renderizar no portal, gerenciar focus, adicionar role="dialog" e aria-labelledby automaticamente. Você forneceu apenas o visual. Se quisesse um design completamente diferente, seria como trocar o CSS — a lógica permaneceria exatamente a igual.

Entendendo os Componentes Compostos

Radix UI trabalha com o padrão de composição. Cada primitivo é uma coleção de sub-componentes que você monta junto. No exemplo anterior, usamos Dialog.Root, Dialog.Trigger, Dialog.Portal, Dialog.Overlay e Dialog.Content. Cada um tem responsabilidades específicas:

  • Root: gerencia o estado aberto/fechado do diálogo inteiro
  • Trigger: o botão que abre (pode ser qualquer elemento com asChild)
  • Portal: renderiza em um portal para evitar problemas de z-index
  • Overlay: o fundo semitransparente
  • Content: o diálogo em si, onde a lógica de focus e keyboard é aplicada

Você não precisa usar todos se não quiser. Se não precisar de um overlay, simplesmente não inclua. Se quiser customizar o trigger, use asChild para passar seu próprio elemento.

Padrões Avançados com Headless Components

Criando um Select Customizado

Selectores são logicamente complexos. Radix UI oferece @radix-ui/react-select que encapsula toda essa complexidade. Vamos usá-lo:

npm install @radix-ui/react-select @radix-ui/react-icons
import React from 'react';
import * as Select from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
import styles from './CustomSelect.module.css';

export function CustomSelect() {
  const [value, setValue] = React.useState('option-1');

  return (
    <Select.Root value={value} onValueChange={setValue}>
      {/* Trigger: o botão que abre o select */}
      <Select.Trigger className={styles.trigger}>
        <Select.Value placeholder="Escolha uma opção" />
        <Select.Icon>
          <ChevronDownIcon />
        </Select.Icon>
      </Select.Trigger>

      {/* Portal e Content: contêm as opções */}
      <Select.Portal>
        <Select.Content className={styles.content}>
          {/* ScrollUpButton aparece quando há scroll */}
          <Select.ScrollUpButton className={styles.scrollButton}>
            ▲
          </Select.ScrollUpButton>

          {/* Viewport contém as opções */}
          <Select.Viewport className={styles.viewport}>
            {/* Grupo de opções relacionadas */}
            <Select.Group>
              <Select.Label className={styles.label}>
                Frutas
              </Select.Label>
              <Select.Item value="option-1" className={styles.item}>
                <Select.ItemText>Maçã</Select.ItemText>
                <Select.ItemIndicator>
                  <CheckIcon />
                </Select.ItemIndicator>
              </Select.Item>
              <Select.Item value="option-2" className={styles.item}>
                <Select.ItemText>Banana</Select.ItemText>
                <Select.ItemIndicator>
                  <CheckIcon />
                </Select.ItemIndicator>
              </Select.Item>
              <Select.Item value="option-3" className={styles.item}>
                <Select.ItemText>Laranja</Select.ItemText>
                <Select.ItemIndicator>
                  <CheckIcon />
                </Select.ItemIndicator>
              </Select.Item>
            </Select.Group>

            <Select.Separator className={styles.separator} />

            <Select.Group>
              <Select.Label className={styles.label}>
                Vegetais
              </Select.Label>
              <Select.Item value="option-4" className={styles.item}>
                <Select.ItemText>Cenoura</Select.ItemText>
                <Select.ItemIndicator>
                  <CheckIcon />
                </Select.ItemIndicator>
              </Select.Item>
              <Select.Item value="option-5" className={styles.item}>
                <Select.ItemText>Brócolis</Select.ItemText>
                <Select.ItemIndicator>
                  <CheckIcon />
                </Select.ItemIndicator>
              </Select.Item>
            </Select.Group>
          </Select.Viewport>

          <Select.ScrollDownButton className={styles.scrollButton}>
            ▼
          </Select.ScrollDownButton>
        </Select.Content>
      </Select.Portal>
    </Select.Root>
  );
}

CSS para o Select customizado:

/* CustomSelect.module.css */

.trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  background: white;
  border: 1px solid #ccc;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  min-width: 200px;
  transition: border-color 200ms;
}

.trigger:hover {
  border-color: #999;
}

.trigger:focus {
  outline: none;
  border-color: #0066cc;
  box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}

.content {
  background: white;
  border: 1px solid #ccc;
  border-radius: 6px;
  box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  animation: slideDown 200ms ease-out;
}

@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.viewport {
  padding: 4px 0;
  max-height: 300px;
  overflow: auto;
}

.scrollButton {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 24px;
  background: #f5f5f5;
  color: #666;
  cursor: pointer;
  font-size: 12px;
}

.scrollButton:hover {
  background: #e8e8e8;
}

.label {
  padding: 8px 12px;
  font-size: 12px;
  font-weight: 600;
  color: #999;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.item {
  padding: 8px 12px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: pointer;
  font-size: 14px;
  color: #1a1a1a;
  transition: background-color 100ms;
  user-select: none;
}

.item:hover {
  background: #f0f0f0;
}

.item[data-state='checked'] {
  background: #e8f0ff;
  color: #0066cc;
}

.item[data-highlighted] {
  background: #f5f5f5;
}

.separator {
  height: 1px;
  background: #e0e0e0;
  margin: 4px 0;
}

Observe que você usou data-state e data-highlighted no CSS. Radix adiciona esses atributos automaticamente — você os aproveita para estilizar sem adicionar lógica extra em JavaScript. A biblioteca gerencia navegação por teclado (setas, Home, End, Type-ahead), acessibilidade ARIA e tudo mais.

Composição e Reutilização de Lógica

O verdadeiro poder emerge quando você encapsula um headless component em sua própria abstração. Vamos criar um wrapper que reutiliza o Select em múltiplos contextos com visuais diferentes:

// useSelectLogic.js - Hook customizado que encapsula lógica de negócio
export function useSelectLogic(items) {
  const [selectedId, setSelectedId] = React.useState(items[0]?.id);
  const selectedItem = items.find(item => item.id === selectedId);

  return {
    selectedId,
    setSelectedId,
    selectedItem,
    items
  };
}

// SelectCompact.jsx - Versão compacta do Select
import * as Select from '@radix-ui/react-select';
import styles from './SelectCompact.module.css';

export function SelectCompact({ items, value, onChange }) {
  return (
    <Select.Root value={value} onValueChange={onChange}>
      <Select.Trigger className={styles.compactTrigger}>
        <Select.Value />
        <Select.Icon>▼</Select.Icon>
      </Select.Trigger>
      <Select.Portal>
        <Select.Content className={styles.compactContent}>
          <Select.Viewport className={styles.compactViewport}>
            {items.map(item => (
              <Select.Item 
                key={item.id} 
                value={item.id}
                className={styles.compactItem}
              >
                <Select.ItemText>{item.label}</Select.ItemText>
              </Select.Item>
            ))}
          </Select.Viewport>
        </Select.Content>
      </Select.Portal>
    </Select.Root>
  );
}

// SelectFull.jsx - Versão completa com detalhes
import * as Select from '@radix-ui/react-select';
import styles from './SelectFull.module.css';

export function SelectFull({ items, value, onChange }) {
  return (
    <Select.Root value={value} onValueChange={onChange}>
      <Select.Trigger className={styles.fullTrigger}>
        <Select.Value placeholder="Selecione um item" />
        <Select.Icon>⌄</Select.Icon>
      </Select.Trigger>
      <Select.Portal>
        <Select.Content className={styles.fullContent}>
          <Select.ScrollUpButton>⬆</Select.ScrollUpButton>
          <Select.Viewport className={styles.fullViewport}>
            {items.map(item => (
              <Select.Item 
                key={item.id} 
                value={item.id}
                className={styles.fullItem}
              >
                <Select.ItemText>
                  <div className={styles.itemContent}>
                    <span className={styles.itemLabel}>{item.label}</span>
                    {item.description && (
                      <span className={styles.itemDescription}>
                        {item.description}
                      </span>
                    )}
                  </div>
                </Select.ItemText>
              </Select.Item>
            ))}
          </Select.Viewport>
          <Select.ScrollDownButton>⬇</Select.ScrollDownButton>
        </Select.Content>
      </Select.Portal>
    </Select.Root>
  );
}

// App.jsx - Utilizando ambas as variações com a mesma lógica
import { useSelectLogic } from './useSelectLogic';
import { SelectCompact } from './SelectCompact';
import { SelectFull } from './SelectFull';

const PRODUCTS = [
  { id: '1', label: 'Notebook' },
  { id: '2', label: 'Mouse', description: 'Sem fio' },
  { id: '3', label: 'Teclado', description: 'Mecânico RGB' }
];

export function App() {
  const { selectedId, setSelectedId, items } = useSelectLogic(PRODUCTS);

  return (
    <div>
      <h2>Versão Compacta</h2>
      <SelectCompact 
        items={items} 
        value={selectedId} 
        onChange={setSelectedId}
      />

      <h2>Versão Completa</h2>
      <SelectFull 
        items={items} 
        value={selectedId} 
        onChange={setSelectedId}
      />
    </div>
  );
}

Aqui você viu o padrão em ação: a mesma lógica (useSelectLogic) alimenta dois componentes visuais completamente diferentes (SelectCompact e SelectFull). Ambos usam Radix UI para a orquestração, mas você controla tudo o mais. Se precisasse de uma terceira variação, seria trivial.

Acessibilidade como Padrão

Por Que Radix UI Torna Acessibilidade Fácil

Acessibilidade é frequentemente negligenciada porque é chata de implementar manualmente. Você precisa se lembrar de adicionar role="dialog", aria-labelledby, aria-describedby, gerenciar focus trap, implementar keyboard navigation, testar com leitores de tela... Radix UI faz tudo isso para você por padrão.

Quando você usa Dialog.Content do Radix, ele automaticamente:
- Adiciona role="dialog"
- Conecta aria-labelledby ao seu Dialog.Title
- Conecta aria-describedby ao seu Dialog.Description
- Implementa focus trap (focus não sai do diálogo enquanto está aberto)
- Permite fechar com Escape
- Restaura focus ao elemento que abriu o diálogo

Você não escreveu uma linha de JavaScript para tudo isso. Verificar a acessibilidade se torna simples — você já começa correto.

Exemplo com @radix-ui/react-tabs:

import React from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import styles from './AccessibleTabs.module.css';

export function AccessibleTabs() {
  return (
    <Tabs.Root defaultValue="tab1" className={styles.tabsRoot}>
      {/* Radix adiciona role="tablist" automaticamente */}
      <Tabs.List className={styles.tabsList}>
        {/* Radix adiciona role="tab" e aria-selected automaticamente */}
        <Tabs.Trigger value="tab1" className={styles.trigger}>
          Descrição
        </Tabs.Trigger>
        <Tabs.Trigger value="tab2" className={styles.trigger}>
          Especificações
        </Tabs.Trigger>
        <Tabs.Trigger value="tab3" className={styles.trigger}>
          Avaliações
        </Tabs.Trigger>
      </Tabs.List>

      {/* Radix adiciona role="tabpanel" automaticamente */}
      <Tabs.Content value="tab1" className={styles.content}>
        <p>Esta é a descrição do produto. Radix mantém este conteúdo no DOM mas o oculta do leitor de tela quando a aba não está ativa.</p>
      </Tabs.Content>

      <Tabs.Content value="tab2" className={styles.content}>
        <ul>
          <li>Material: Plástico de alta resistência</li>
          <li>Dimensões: 10cm x 10cm x 5cm</li>
          <li>Peso: 250g</li>
        </ul>
      </Tabs.Content>

      <Tabs.Content value="tab3" className={styles.content}>
        <p>⭐⭐⭐⭐⭐ Excelente produto!</p>
      </Tabs.Content>
    </Tabs.Root>
  );
}
.tabsRoot {
  display: flex;
  flex-direction: column;
  width: 100%;
}

.tabsList {
  display: flex;
  border-bottom: 2px solid #e0e0e0;
}

.trigger {
  padding: 12px 16px;
  background: none;
  border: none;
  cursor: pointer;
  font-weight: 500;
  color: #666;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  transition: all 200ms;
}

.trigger:hover {
  color: #333;
}

.trigger[data-state='active'] {
  color: #0066cc;
  border-bottom-color: #0066cc;
}

.content {
  padding: 16px 0;
  line-height: 1.6;
}

Observe que você nem pensou em ARIA — Radix cuidou. Navegue com Tab, depois com as setas esquerda/direita e tudo funciona perfeitamente para usuários de teclado e leitores de tela.

Conclusão

Aprendemos que headless components revolucionam como você constrói interfaces ao separar inteligência de apresentação. Radix UI oferece uma implementação madura dessa filosofia, fornecendo lógica e acessibilidade sem impor nenhum visual — você fica com a liberdade de design e a tranquilidade de acessibilidade garantida desde o início. O padrão de composição permite reutilizar a mesma lógica em múltiplos contextos visuais, reduzindo drasticamente duplicação de código e facilitando manutenção.

Referências


Artigos relacionados