shadcn/ui e Radix: como manter a acessibilidade ao personalizar componentes

Na semana passada, um colega me perguntou: “Por que não consigo acionar este botão pelo teclado?”
Fiquei surpreso. Estávamos usando shadcn/ui; como isso podia acontecer? Ao abrir o DevTools, descobri que ele havia colocado uma <div> em volta de Tooltip.Trigger para adicionar um estilo personalizado. Era exatamente aí que estava o problema.
Para ser sincero, eu também já caí em armadilhas parecidas. Quando comecei a usar shadcn/ui, achava que seus componentes podiam ser alterados à vontade, afinal, todo o código era copiado para dentro do projeto. Mudar um estilo, trocar uma tag ou adicionar um wrapper parecia inofensivo. Até que, em um teste de QA, descobrimos que a interação por teclado havia parado de funcionar, o leitor de tela não conseguia anunciar o conteúdo e todo o fluxo de interação estava quebrado.
Foi então que percebi que a “liberdade” oferecida pelo shadcn/ui tem um preço. Ele entrega o código-fonte, mas por trás desse código está a mágica de acessibilidade do Radix. Se você alterar as coisas sem cuidado, essa camada deixa de funcionar.
Neste artigo, vamos entender a relação entre shadcn/ui e Radix, com foco em como preservar a acessibilidade ao personalizar componentes. Ao terminar a leitura, você deverá compreender como usar asChild, como lidar com o gerenciamento de foco e como os atributos ARIA são herdados. Assim, na próxima vez que modificar um componente, saberá o que pode mudar e em que não deve mexer.
shadcn/ui e Radix: qual é exatamente a relação?
Primeiro, um ponto que muita gente ainda não entendeu: shadcn/ui não é um pacote npm.
Você não consegue instalá-lo com npm install @shadcn/ui. Na prática, ele é uma “plataforma de distribuição de código”: fornece o código-fonte dos componentes, você o copia para o projeto e, a partir daí, esse código passa a ser totalmente seu. Pode alterar ou excluir o que quiser.
Então, de onde vêm os recursos de acessibilidade desses componentes? Do Radix.
Radix UI é uma biblioteca de componentes sem estilo, também chamados de Primitives. Ela não fornece a aparência, apenas o comportamento: como gerenciar o foco ao abrir um Dialog, como um Dropdown Menu responde às setas para cima e para baixo e como um Tooltip se comporta para leitores de tela. Esses recursos seguem as especificações WAI-ARIA e foram testados com leitores de tela populares, como NVDA, JAWS e VoiceOver.
O shadcn/ui adiciona uma camada de estilos Tailwind CSS sobre o Radix. Ele oferece uma aparência bem trabalhada enquanto mantém, por baixo, o comportamento acessível do Radix. Ao copiar o código de um botão, você pode enxergar apenas algumas classes Tailwind, mas também existe ali toda a lógica do Radix.
Em termos mais diretos:
- O Radix cuida de “funcionar”: atributos aria, role, gerenciamento de foco e navegação por teclado
- O shadcn/ui cuida de “ter uma boa aparência”: estilos Tailwind e consistência visual
Por isso, ao modificar componentes do shadcn/ui, lembre-se de que você está mexendo na “camada superficial”, enquanto a lógica de comportamento na base vem do Radix. A superfície aceita muita personalização; um erro na base, porém, causa problemas.
A propriedade asChild: mágica ou armadilha?
asChild é uma propriedade muito particular do Radix. A maioria das “partes” dos componentes Radix oferece suporte a ela.
O que isso significa? Por padrão, por exemplo, Tooltip.Trigger renderiza um elemento <button>. Mas talvez você queira aplicar o Tooltip a um link. Nesse caso, use asChild:
<Tooltip.Trigger asChild>
<a href="/help">Central de ajuda</a>
</Tooltip.Trigger>
Depois que você define asChild={true}, o Radix deixa de renderizar seu próprio <button>. Em vez disso, ele “clona” o elemento filho fornecido e repassa a ele seus comportamentos e atributos. Assim, o link recebe todos os recursos de um Tooltip Trigger: o Tooltip aparece ao passar o mouse, também pode ser acionado pelo foco do teclado e recebe os atributos aria corretos.
Parece muito conveniente.
Mas é aí que também mora a armadilha.
Se você trocar o filho por um elemento que não aceita foco, toda a acessibilidade desaparece.
// ❌ Exemplo incorreto
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">Clique em mim</div>
</Tooltip.Trigger>
Uma div não recebe foco pelo teclado, a menos que você adicione tabIndex={0} manualmente, e não responde às teclas Enter ou Espaço. O leitor de tela também não a interpreta como botão. Para uma pessoa que navega pelo teclado, esse Tooltip simplesmente não existe no fluxo.
A documentação oficial do Radix é explícita: “Se você trocasse o elemento por uma div, ele deixaria de ser acessível.”
Na prática, é claro, quase nunca substituímos diretamente o elemento por uma div. O mais comum é usar um componente React próprio:
<Tooltip.Trigger asChild>
<MyButton>Clique em mim</MyButton>
</Tooltip.Trigger>
Isso funciona, mas há duas regras obrigatórias:
1. Seu componente deve repassar as props
Quando o Radix clona o elemento filho, ele envia vários atributos: manipuladores de eventos, atributos aria e uma ref. Se seu componente não receber esses atributos, o recurso deixa de funcionar.
// ❌ Incorreto: não recebe props
const MyButton = () => <button className="btn">...</button>
// ✅ Correto: repassa todas as props
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. Seu componente deve encaminhar a ref
Às vezes, o Radix precisa acessar diretamente o elemento DOM, por exemplo, para medir suas dimensões ou gerenciar o foco. Se a ref não for encaminhada, ocorrerá um erro.
// ❌ Incorreto: não recebe a ref
const MyButton = (props) => <button {...props}>...</button>
// ✅ Correto: encaminha a ref
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
Na verdade, essas duas regras não são importantes apenas para o Radix. Todo “componente folha” deveria receber todas as props e encaminhar a ref. É uma prática básica.
Há ainda um uso interessante: vários componentes Radix podem ser aninhados.
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>Abrir modal</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
Um único botão funciona ao mesmo tempo como Tooltip Trigger e Dialog Trigger. Os dois comportamentos se combinam sem problemas.
Gerenciamento de foco e navegação por teclado
O gerenciamento de foco é um dos aspectos mais ignorados da acessibilidade.
Muita gente pensa apenas em deixar a interface bonita e esquece que nem todo mundo usa o mouse. Pessoas que usam teclado ou leitores de tela dependem completamente da posição do foco para interagir.
O Radix lida automaticamente com muitos desses detalhes. Veja um exemplo:
Quando um AlertDialog abre, o foco vai automaticamente para o botão Cancel.
Esse detalhe foi cuidadosamente pensado. Um AlertDialog costuma ser usado para confirmar ações perigosas, como excluir um item ou sair de uma conta. Depois que o modal abre, a ação mais provável é “cancelar”, não “confirmar”. Com o foco diretamente em Cancel, basta pressionar Enter para fechar o modal e evitar uma ação acidental.
E se o foco estivesse no botão Confirm? Uma pessoa poderia pressionar Enter sem querer e excluir algo imediatamente. Um desastre.
Esse comportamento é implementado pelo Radix com base nas práticas de autoria do WAI-ARIA. Você não precisa programá-lo por conta própria.
Mas surge um problema: se você personalizar o conteúdo do AlertDialog, o foco pode acabar no lugar errado.
Suponha que você adicione um campo de entrada ao modal:
<AlertDialog.Content>
<AlertDialog.Title>Confirmar exclusão?</AlertDialog.Title>
<AlertDialog.Description>Digite "DELETE" para confirmar</AlertDialog.Description>
<input placeholder="Digite DELETE" /> {/* Adicionado por você */}
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action>Confirmar</AlertDialog.Action>
</AlertDialog.Content>
Para onde o foco vai quando esse modal abre?
Por padrão, o Radix procura o primeiro elemento que pode receber foco. Como o input aparece antes de Cancel, o foco vai para ele. A pessoa precisa pressionar Tab várias vezes para chegar ao botão Cancel. Isso interrompe o fluxo esperado.
A solução é usar autoFocus para definir o alvo do foco ou ajustar a ordem dos elementos.
<AlertDialog.Content>
<AlertDialog.Title>Confirmar exclusão?</AlertDialog.Title>
<AlertDialog.Description>Digite "DELETE" para confirmar</AlertDialog.Description>
<AlertDialog.Cancel autoFocus>Cancelar</AlertDialog.Cancel> {/* Força o foco */}
<input placeholder="Digite DELETE" />
<AlertDialog.Action>Confirmar</AlertDialog.Action>
</AlertDialog.Content>
Mova Cancel para antes do input ou adicione autoFocus a ele. Assim, o foco não vai para o lugar errado.
A navegação por teclado pode apresentar problemas semelhantes.
No componente Tabs, a pessoa alterna entre as abas com as setas para a esquerda e para a direita, seguindo o comportamento padrão do WAI-ARIA. Se você adicionar um estilo personalizado à aba e, sem querer, sobrescrever role="tab", a navegação por teclado deixará de funcionar.
Em um Dropdown Menu, as setas para cima e para baixo selecionam itens, Enter confirma e Esc fecha o menu. Tudo isso é tratado internamente pelo Radix. No entanto, se você adicionar onClick a um item em vez de usar onSelect, pode prejudicar o comportamento pelo teclado.
O método de teste é simples e direto: deixe o mouse de lado e percorra todo o fluxo do componente usando apenas o teclado.
- A tecla Tab entra no componente?
- As setas alternam as opções?
- Enter executa a ação?
- Esc fecha o modal?
Se qualquer etapa travar, há um problema de acessibilidade.
Herança automática dos atributos ARIA
Em relação aos atributos ARIA, o Radix poupa bastante trabalho.
Ele adiciona automaticamente aos componentes os valores corretos de role e os atributos aria-*. Por exemplo:
- Dialog recebe
role="dialog"earia-modal="true" - Tabs.Tab recebe
role="tab"earia-selected - Switch recebe
role="switch"earia-checked
Você não precisa cuidar disso: o Radix já resolve internamente.
Mas há algo que você precisa fazer: fornecer um nome acessível ao controle.
Quem usa leitor de tela precisa saber para que serve o botão, qual é o título do modal ou o que deve ser preenchido no campo. Sem um nome, só resta adivinhar.
O Radix oferece o primitive Label para ajudar:
<Label.Root htmlFor="email-input">Endereço de e-mail</Label.Root>
<Input id="email-input" />
Esse Label.Root é associado automaticamente ao input. Quando o leitor de tela o anuncia, primeiro diz “Endereço de e-mail” e depois informa o valor do campo.
Para controles personalizados que não são inputs nativos, você precisa fornecer o nome manualmente.
<Switch aria-label="Ativar modo noturno" />
<Tabs.Tab aria-label="Detalhes do produto" />
Outra opção é usar aria-labelledby para associar um texto visível:
<div id="mode-label">Modo noturno</div>
<Switch aria-labelledby="mode-label" />
Para validar, ative um leitor de tela e percorra o fluxo.
No Mac, você pode usar o VoiceOver, ativado com Cmd+F5. No Windows, há o NVDA, disponível gratuitamente. Ouça como cada componente é anunciado. Se o leitor disser apenas “botão”, em vez de “botão Enviar pedido”, está faltando um nome acessível.
Mais um ponto: contraste de cores.
O Radix não controla os estilos, portanto o contraste é sua responsabilidade. As WCAG exigem uma relação de contraste mínima de 4,5:1 entre texto e fundo para texto normal, ou 3:1 para texto grande. As cores padrão do shadcn/ui normalmente atendem a esses valores, mas é preciso ter cuidado ao personalizá-las.
Uma ferramenta útil é o WebAIM Contrast Checker. Basta informar as cores de primeiro plano e de fundo para calcular o contraste.
Checklist prático
Sempre que personalizar um componente shadcn/ui, use esta lista para fazer a validação:
Verificação de asChild
- O elemento filho de
asChildaceita foco? (button/a/input, não div) - O componente personalizado repassa todas as props?
- O componente personalizado encaminha a ref?
Verificação do gerenciamento de foco
- Ao abrir o modal, o foco vai para o lugar correto?
- Ao fechar o modal, o foco retorna ao elemento que o acionou?
- Quando há elementos focáveis aninhados, a ordem do foco é coerente?
Verificação da navegação por teclado
- A tecla Tab entra no componente?
- As setas alternam as opções em Tabs e Dropdown?
- Enter executa a ação?
- Esc fecha o modal?
- A tecla Espaço alterna o estado de Switch e Checkbox?
Verificação de ARIA
- Todos os controles têm um nome acessível?
- O leitor de tela anuncia corretamente a função e o estado?
- Mudanças dinâmicas de estado usam uma região aria-live apropriada?
Verificação visual
- O indicador de foco está claramente visível?
- O contraste de cores atende aos valores de 4,5:1 ou 3:1?
- As informações não são transmitidas apenas por cores, mas também contam com ícones ou texto?
Ferramentas de teste
- Teste com teclado: deixe o mouse de lado e percorra todo o fluxo apenas com o teclado
- Leitor de tela: VoiceOver no Mac ou NVDA no Windows
- Automação: extensão axe DevTools para o navegador
Conclusão
O shadcn/ui oferece a liberdade de controlar o código, mas essa liberdade tem limites.
O limite está no comportamento acessível do Radix. Você pode alterar estilos, layout e nomes de classes, mas não pode comprometer a lógica de comportamento na base. Ao trocar um button por uma div ou esquecer de repassar as props, você prejudica diretamente quem depende do teclado.
Guarde estes pontos:
- Ao usar asChild: o elemento filho deve aceitar foco, e componentes personalizados precisam repassar as props e encaminhar a ref
- Gerenciamento de foco: ao personalizar o conteúdo de um modal, confira para onde o foco foi
- Atributos ARIA: o Radix adiciona role automaticamente, mas você precisa fornecer o label
Na próxima vez que modificar um componente, primeiro percorra o fluxo com o teclado. Se encontrar um problema, corrija-o antes que o QA precise apontá-lo.
No fim das contas, acessibilidade não é um “recurso extra”, mas um requisito básico. shadcn/ui e Radix já fizeram a parte mais difícil; o que resta é não desfazer esse trabalho.
FAQ
Qual é a relação entre shadcn/ui e Radix UI?
Como usar a propriedade asChild sem prejudicar a acessibilidade?
O que observar no gerenciamento de foco ao personalizar o conteúdo de um modal?
Como testar se a acessibilidade de um componente está funcionando?
O Radix adicionou os atributos aria automaticamente. Ainda preciso fazer algo?
12 min de leitura · Publicado em: 30 mar 2026 · Atualizado em: 4 set 2026
Tailwind e shadcn/ui na prática
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Padrões de composição no shadcn/ui: boas práticas para integrar vários componentes
Aprenda as melhores práticas de composição no shadcn/ui e domine combinações comuns como Dialog + Form e DataTable + DropdownMenu, além de tópicos avançados como Context, gerenciamento de estado e otimização de desempenho.
Parte 8 de 14
Próximo
Dialog, Sheet e Popover: acessibilidade e gerenciamento de foco em componentes de sobreposição
Uma análise detalhada da acessibilidade e do gerenciamento de foco nos componentes Dialog, Sheet e Popover do shadcn/ui, incluindo padrões WCAG, atributos ARIA, navegação por teclado, armadilha de foco e exemplos completos de código
Parte 10 de 14



Comentários
Entre com GitHub para comentar