<h2>O Que São Headless Components?</h2>
<p>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.</p>
<p>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.</p>
<h2>Introdução ao Radix UI</h2>
<h3>O que é Radix UI e por que escolher?</h3>
<p>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.</p>
<p>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.</p>
<h3>Instalação e Configuração Inicial</h3>
<p>Para começar, você precisa apenas instalar o pacote do componente que vai usar. Vamos iniciar com um simples exemplo usando <code>@radix-ui/react-dialog</code>:</p>
<pre><code class="language-bash">npm install @radix-ui/react-dialog</code></pre>
<p>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.</p>
<h2>Criando Seu Primeiro Headless Component com Radix UI</h2>
<h3>Exemplo Prático: Dialog (Modal) Personalizado</h3>
<p>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:</p>
<pre><code class="language-jsx">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>
);
}</code></pre>
<p>Agora o CSS do seu módulo, que demonstra a liberdade visual que você tem:</p>
<pre><code class="language-css">/ 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;
}</code></pre>
<p>Aqui está o ponto crucial: Radix UI forneceu toda a orquestração — fechar quando você pressiona Escape, renderizar no portal, gerenciar focus, adicionar <code>role="dialog"</code> e <code>aria-labelledby</code> automaticamente. Você forneceu apenas o visual. Se quisesse um design completamente diferente, seria como trocar o CSS — a lógica permaneceria exatamente a igual.</p>
<h3>Entendendo os Componentes Compostos</h3>
<p>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 <code>Dialog.Root</code>, <code>Dialog.Trigger</code>, <code>Dialog.Portal</code>, <code>Dialog.Overlay</code> e <code>Dialog.Content</code>. Cada um tem responsabilidades específicas:</p>
<ul>
<li><strong>Root</strong>: gerencia o estado aberto/fechado do diálogo inteiro</li>
<li><strong>Trigger</strong>: o botão que abre (pode ser qualquer elemento com <code>asChild</code>)</li>
<li><strong>Portal</strong>: renderiza em um portal para evitar problemas de z-index</li>
<li><strong>Overlay</strong>: o fundo semitransparente</li>
<li><strong>Content</strong>: o diálogo em si, onde a lógica de focus e keyboard é aplicada</li>
</ul>
<p>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 <code>asChild</code> para passar seu próprio elemento.</p>
<h2>Padrões Avançados com Headless Components</h2>
<h3>Criando um Select Customizado</h3>
<p>Selectores são logicamente complexos. Radix UI oferece <code>@radix-ui/react-select</code> que encapsula toda essa complexidade. Vamos usá-lo:</p>
<pre><code class="language-bash">npm install @radix-ui/react-select @radix-ui/react-icons</code></pre>
<pre><code class="language-jsx">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>
);
}</code></pre>
<p>CSS para o Select customizado:</p>
<pre><code class="language-css">/ 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;
}</code></pre>
<p>Observe que você usou <code>data-state</code> e <code>data-highlighted</code> 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.</p>
<h3>Composição e Reutilização de Lógica</h3>
<p>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:</p>
<pre><code class="language-jsx">// 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>
);
}</code></pre>
<p>Aqui você viu o padrão em ação: a mesma lógica (<code>useSelectLogic</code>) alimenta dois componentes visuais completamente diferentes (<code>SelectCompact</code> e <code>SelectFull</code>). Ambos usam Radix UI para a orquestração, mas você controla tudo o mais. Se precisasse de uma terceira variação, seria trivial.</p>
<h2>Acessibilidade como Padrão</h2>
<h3>Por Que Radix UI Torna Acessibilidade Fácil</h3>
<p>Acessibilidade é frequentemente negligenciada porque é chata de implementar manualmente. Você precisa se lembrar de adicionar <code>role="dialog"</code>, <code>aria-labelledby</code>, <code>aria-describedby</code>, gerenciar focus trap, implementar keyboard navigation, testar com leitores de tela... Radix UI faz tudo isso para você por padrão.</p>
<p>Quando você usa <code>Dialog.Content</code> do Radix, ele automaticamente:</p>
<ul>
<li>Adiciona <code>role="dialog"</code></li>
<li>Conecta <code>aria-labelledby</code> ao seu <code>Dialog.Title</code></li>
<li>Conecta <code>aria-describedby</code> ao seu <code>Dialog.Description</code></li>
<li>Implementa focus trap (focus não sai do diálogo enquanto está aberto)</li>
<li>Permite fechar com Escape</li>
<li>Restaura focus ao elemento que abriu o diálogo</li>
</ul>
<p>Você não escreveu uma linha de JavaScript para tudo isso. Verificar a acessibilidade se torna simples — você já começa correto.</p>
<p>Exemplo com <code>@radix-ui/react-tabs</code>:</p>
<pre><code class="language-jsx">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>
);
}</code></pre>
<pre><code class="language-css">.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;
}</code></pre>
<p>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.</p>
<h2>Conclusão</h2>
<p>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.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://www.radix-ui.com" target="_blank" rel="noopener noreferrer">Radix UI Official Documentation</a></li>
<li><a href="https://www.radix-ui.com/docs/primitives/components/dialog" target="_blank" rel="noopener noreferrer">Radix UI Dialog Component</a></li>
<li><a href="https://www.w3.org/WAI/ARIA/apg/" target="_blank" rel="noopener noreferrer">WAI-ARIA Authoring Practices Guide</a></li>
<li><a href="https://reach.tech/" target="_blank" rel="noopener noreferrer">Reach UI - Accessible Components for React</a></li>
<li><a href="https://www.smashingmagazine.com/2022/05/building-composable-accessible-web-components/" target="_blank" rel="noopener noreferrer">Headless Components: A Pattern for Composable User Interfaces</a></li>
</ul>
<p><!-- FIM --></p>