Alternar tema

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

Easton editorial illustration: learning console with milestone tokens

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" e aria-modal="true"
  • Tabs.Tab recebe role="tab" e aria-selected
  • Switch recebe role="switch" e aria-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 asChild aceita 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?
O shadcn/ui é uma plataforma de distribuição de código: você copia o código-fonte para o projeto. Os recursos de acessibilidade vêm do Radix Primitives usado como base, responsável por atributos aria, gerenciamento de foco e navegação por teclado. O shadcn/ui cuida dos estilos com Tailwind.
Como usar a propriedade asChild sem prejudicar a acessibilidade?
Há três pontos principais: o elemento filho deve aceitar foco (button, a ou input), e não ser uma div; componentes personalizados devem repassar as props, por exemplo, props =&gt; &lt;button {...props} /&gt;; e também devem encaminhar a ref.
O que observar no gerenciamento de foco ao personalizar o conteúdo de um modal?
Por padrão, o AlertDialog coloca o foco no botão Cancel. Se você adicionar um campo de entrada ou outro elemento que aceite foco, o foco pode ir para o lugar errado. Use autoFocus para definir o alvo ou ajuste a ordem dos elementos.
Como testar se a acessibilidade de um componente está funcionando?
O método mais simples é deixar o mouse de lado e percorrer todo o fluxo apenas com o teclado. A tecla Tab entra no componente? As setas alternam as opções? Enter executa a ação? Esc fecha o modal? Depois, ative um leitor de tela — VoiceOver no Mac ou NVDA no Windows — e ouça todo o fluxo.
O Radix adicionou os atributos aria automaticamente. Ainda preciso fazer algo?
Você precisa fornecer um nome acessível para o controle. Quem usa leitor de tela precisa saber para que serve o botão ou qual é o título do modal. Use aria-label ou aria-labelledby para associar o controle a um texto visível.

12 min de leitura · Publicado em: 30 mar 2026 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog