Estilizando o UI SDK da Vercel com Tailwind: data-slot, atributos de estado e animações de entrada e saída
Estilize o UI SDK da Vercel com Tailwind v4 e tokens shadcn: data-slot, atributos de estado, variáveis CSS e animação de entrada e saída em quatro componentes.
Preet Suthar · · 9 min de leitura
- React
- UI SDK
- Tailwind CSS
- shadcn/ui
A Vercel Labs publicou o UI SDK no npm em 12 de novembro de 2026. É um conjunto de componentes sem estilo e acessíveis para React, Vue, Svelte, Solid e Web Components, construídos sobre uma biblioteca central de comportamentos. Os componentes cuidam de foco, teclado e ARIA. A aparência é com você.
Este post estiliza quatro deles com Tailwind CSS v4 e tokens de tema no estilo shadcn: um Select, um Popover, um Rating e um Color Picker. Cada um mostra um gancho de estilização diferente. Todo snippet abaixo roda em um app de teste feito com Vite 8, React 19.3, Tailwind CSS 4.3 e @ui-sdk/react e @ui-sdk/core na versão 0.1.0-beta.1. O UI SDK está em beta, e a documentação diz que as APIs ainda podem mudar, então confira a versão que você instalar.
Como a estilização funciona no UI SDK
O UI SDK tem três camadas. @ui-sdk/core guarda comportamentos como roving focus, typeahead e popups. Uma primitiva, escrita com definePrimitive, combina comportamentos com estado e slots nomeados. Um adaptador de framework como @ui-sdk/react renderiza cada slot como uma parte que você importa, como SelectTrigger ou RatingItem.
- Comportamentos
@ui-sdk/coreFoco, teclas, seleção, popups, arrastar e soltar. - Primitiva
definePrimitiveEstado, uma API e slots nomeados em uma única definição. - Partes
@ui-sdk/reactCada slot é renderizado comdata-slot, atributos de estado e variáveis CSS. - Seus estilos
classNameUtilitários do Tailwind ou CSS puro que miram esses ganchos.
<div data-slot="select-item" data-highlighted data-selected role="option">
className="data-highlighted:bg-accent data-selected:font-medium"
Seus estilos se conectam às partes por meio de três ganchos:
data-slot: toda parte tem um, comodata-slot="select-item". Ele nomeia a parte para CSS global e para agentes.- Atributos de estado:
data-open,data-highlighted,data-selected,data-disablede assim por diante. A maioria está presente ou ausente. Alguns carregam um valor, comodata-side="top"oudata-orientation="vertical". - Variáveis CSS: valores que uma classe não consegue expressar, como
--anchor-widthem um popup ou--rating-fillem uma estrela.
className e style também podem ser funções do estado de uma parte, como o Base UI permite. Com Tailwind você raramente precisa disso, porque o v4 tem uma variante para qualquer atributo data: data-highlighted:bg-accent se aplica enquanto data-highlighted está presente.
A documentação pede que você estilize esses atributos, e não IDs gerados, que podem mudar. Cada página de componente tem uma seção Styling e uma seção Data Attributes que listam exatamente o que cada parte expõe. Essa lista é o contrato.
Configure o Tailwind v4 e a layer ui-sdk
O UI SDK precisa de um pouco de CSS próprio, por exemplo para manter oculto um popup fechado. Ele adiciona esse CSS em runtime, em uma cascade layer chamada ui-sdk. Declare a layer primeiro no seu stylesheet global, acima do import do Tailwind, para que seus estilos vençam onde quer que a página insira o CSS de runtime:
A lista completa de tokens é a que o shadcn/ui gera, com um bloco .dark que sobrescreve os mesmos nomes. Eu a encurtei aqui. Nada no UI SDK lê esses tokens. Eles só importam porque as classes abaixo os usam, o que significa que um componente do UI SDK e um do shadcn/ui podem compartilhar um tema.
Não há provider para montar nem stylesheet para importar do pacote. Se você usa uma Content Security Policy sem 'unsafe-inline' para estilos, leia a seção de CSP do guia Styling and Animation, porque o CSS de runtime precisa de um nonce ou de constructed style sheets.
Select: atributos de estado e uma lista animada
Um Select tem um label, um trigger, um valor, um popup de conteúdo e itens. Este mostra um seletor de função com uma marca de seleção no item escolhido e uma escala com fade curta quando a lista abre e fecha:

O que cada classe lê:
data-open:bg-accentmantém o trigger destacado enquanto a lista está aberta.data-placeholder:estiliza o trigger enquanto nada está selecionado. Com um valor padrão, ele nunca se aplica aqui, mas não custa nada mantê-lo.min-w-(--anchor-width)deixa a lista pelo menos tão larga quanto o trigger. O Tailwind v4 escreve o valor de uma variável CSS como(--name), semvar().origin-(--transform-origin)faz a lista crescer a partir do lado em que ela abre. O UI SDK define essa variável no conteúdo.data-highlighted:marca o item sob o ponteiro ou com foco do teclado. Itens selecionados recebemdata-selected, eSelectItemIndicatorsó é renderizado dentro de um item selecionado.
Passar items é opcional, mas a documentação recomenda: o trigger pode mostrar o label selecionado antes de a lista ser renderizada, e a ponte para agentes consegue ler as opções com a lista fechada.
Se você precisa diferenciar o destaque por teclado do destaque por ponteiro, o conteúdo tem data-modality="keyboard" ou "pointer". Um anel de foco que só aparece para quem usa teclado é in-data-[modality=keyboard]:data-highlighted:ring-2 no item.
O ciclo de vida do popup
A animação acima usa dois atributos que só existem durante uma transição. O conteúdo recebe data-starting-style no primeiro frame, para que a transição tenha um estado de partida. Ao fechar, ele recebe data-ending-style e continua montado até a transição ou animação terminar.
- FechadoFora da página
- Primeiro frame
data-opendata-starting-styleEstilize o estado de onde a animação parte - Aberto
data-openTransiciona para seus estilos normais - Fechando
data-closeddata-ending-styleContinua montado até a saída terminar - DesmontadoOu oculto, com keepMounted
Três detalhes fazem isso funcionar na prática.
Primeiro, coloque os estilos de saída em data-ending-style, não em data-closed. data-closed descreve o estado, mas a documentação é explícita: ele não mantém o elemento na página durante a saída.
Segundo, os popups do UI SDK usam a top layer do navegador. O conteúdo é renderizado no lugar, como filho do componente, e o navegador o eleva acima da página. No meu app de teste, o conteúdo aberto tinha popover="manual" e casava com :popover-open. Não existe parte Portal nem Positioner, e o z-index não decide se ele fica acima de um cabeçalho sticky.
Terceiro, se uma biblioteca de animação como o Motion precisa que o conteúdo fechado continue no DOM, defina keepMounted. onOpenChangeComplete roda depois que a abertura ou o fechamento termina, incluindo a animação.
Popover: uma saída mais rápida que a entrada
Entradas e saídas não precisam do mesmo tempo. Um popover que desliza para dentro em 200ms e some em 100ms parece mais rápido do que um que leva 200ms nos dois sentidos. Como a saída tem seu próprio atributo, o Tailwind pode dar a ela sua própria duração:

A entrada vai de -translate-y-1, scale-95 e opacity-0 até o estado de repouso. A saída só reduz a escala e faz fade, com data-ending-style:duration-100. motion-reduce:transition-none desliga as duas para quem pede menos movimento, e o popover continua abrindo e fechando.
PopoverTitle e PopoverDescription importam além da aparência. O conteúdo é um dialog, e essas partes dão a ele um nome e uma descrição acessíveis. Sem um título, dê ao conteúdo um aria-label.
Rating: estilize o preenchimento, não as estrelas
Um Rating é um grupo de radios desenhado como estrelas. Você renderiza um RatingItem e o Rating o repete para cada estrela. Cada item informa em --rating-fill, de 0 a 1, quanto da sua estrela a nota exibida cobre. data-filled marca estrelas inteiras, data-partial marca a estrela onde termina uma meia nota, e data-highlighted marca as estrelas até a nota que o ponteiro está pré-visualizando.
--rating-fill permite que meias estrelas venham de um gradiente em vez de dois ícones:

Com step={0.5}, cada meia estrela é seu próprio radio, chamado "3.5 stars" e assim por diante. Os radios são botões que o Rating posiciona sobre o seu ícone com estilos inline, e o item recebe position: relative do mesmo jeito. Por isso o span pode usar absolute inset-0 sem nenhuma classe extra.
O teclado funciona sem nenhum trabalho da sua parte. No meu teste, focar a estrela marcada e apertar a seta para a direita mudou a nota de 3,5 para 4, e o preenchimento acompanhou.
Mantenha a máscara fora do item focável
Minha primeira versão colocava a máscara de estrela no próprio RatingItem. Parecia certo até eu navegar com Tab: uma máscara CSS corta tudo o que o elemento pinta, inclusive contornos, então o anel de foco sumiu. Mover a máscara para um span interno resolveu. O item mantém seu contorno arredondado via has-focus-visible:, e [&_button]:outline-none esconde o anel padrão do navegador no botão radio de meia largura lá dentro, que de outra forma desenha uma caixa fina em volta de meia estrela.

Isso não é específico do UI SDK, mas máscaras em formato de ícone são um jeito comum de desenhar avaliações, então vale testar com o teclado antes de publicar.
Color Picker: as variáveis CSS fazem o desenho
O Color Picker expõe mais variáveis que os outros três. Toda parte escreve a cor atual em --color-picker-color. A área recebe --color-area-background, um conjunto de gradientes para seus dois canais, e cada slider recebe --color-slider-background. Suas classes dão tamanho às partes e usam essas variáveis como fundo.

Coisas que vale saber antes de copiar isto:
- O Popover fica dentro do ColorPicker. Assim, as partes no popup e a amostra no trigger editam e mostram a mesma cor.
bg-(image:--color-area-background)é a dica de tipo do Tailwind v4. Semimage:, o Tailwind leria a variável como uma cor, e os gradientes não seriam pintados.- Os thumbs recebem
position: absolutee sua posição em porcentagem como estilos inline. Sua classe só precisa centralizar cada thumb no seu ponto, que é o que-translate-1/2faz. O thumb do slider também precisa detop-1/2, porque o estilo inline dele só define a posição ao longo da trilha. - Para leitores de tela, a amostra é uma imagem com o nome da cor. Na minha build ela foi anunciada como "vivid purple" para
#6366f1. Dê você mesmo um nome ao trigger, porque o texto visível sozinho pode não dizer para que serve a cor. - Para OKLCH, adicione
ColorPickerAreaCanvasdentro da área. Um gradiente não consegue desenhar a borda do gamut do OKLCH, então o canvas a pinta pixel por pixel.
Estilizando por data-slot com CSS puro
Classes utilitárias em cada parte são uma opção. A outra é um stylesheet baseado em data-slot, que deixa o markup curto e concentra o visual de um componente em um só lugar:
A documentação do UI SDK também estiliza as próprias demos com CSS puro. Funciona porque data-slot é um dos atributos que uma parte sempre mantém: a documentação o lista ao lado de roles e tabindex como atributos gerenciados que suas props não conseguem sobrescrever. Em um projeto no estilo shadcn/ui, em que cada arquivo de componente envolve as partes e adiciona as próprias classes, utilitários nas partes costumam ser mais simples. Um tema em CSS puro é mais fácil de trocar por inteiro.
Levando classes estilo shadcn do Radix ou do Base UI
Se seus componentes vêm do shadcn/ui, a maior parte das suas classes continua valendo. As diferenças estão em alguns nomes de atributos e na estrutura do popup.
| Aspecto | Radix | Base UI | UI SDK |
|---|---|---|---|
| Estado aberto | data-[state=open]: | data-open: | data-open: |
| Estilos de entrada | keyframes em data-[state=open] | data-starting-style: | data-starting-style: |
| Estilos de saída | keyframes em data-[state=closed] | data-ending-style: | data-ending-style: |
| Transform origin | --radix-popover-content-transform-origin | --transform-origin | --transform-origin |
| Largura do trigger | --radix-popover-trigger-width | --anchor-width | --anchor-width |
| Partes do popup | Portal, Content | Portal, Positioner, Popup | Só o Content |
Vindo do Base UI, os nomes de atributos dos popups são os mesmos, então as strings de classe em grande parte podem ser coladas direto. Você apaga os wrappers Portal e Positioner e move props de posicionamento como side, align e sideOffset para o conteúdo. Vindo do Radix, você troca as variantes data-[state=…] e as animações com keyframes pelos atributos de início e fim. Em qualquer caso, confira a lista Data Attributes de cada componente, porque nomes que parecem compartilhados podem ser diferentes. O Select do Base UI, por exemplo, tem seu próprio conjunto de atributos, e o do UI SDK também.
A comparação das três bibliotecas como um todo, além da estilização, está em UI SDK vs Base UI vs Radix.
Onde eu tropecei
Uma lista curta, para você não repetir:
- Estilizar
data-closedpara a saída. Nada anima. Usedata-ending-style. - Esquecer o
@layer ui-sdk;. Sem ele primeiro, o CSS de runtime pode cair depois dos seus estilos na cascata. - Máscaras em partes focáveis. Elas cortam o contorno de foco. Aplique a máscara em um elemento interno.
- Ler
--available-heightem um filho. A documentação avisa que ela não é herdada. Copie-a no popup, por exemplo[--list-height:var(--available-height)], e leiamax-h-(--list-height)mais abaixo. - Gradientes a partir de variáveis. Use
bg-(image:--name), nãobg-(--name).
O UI SDK é um beta, com um dia de vida quando escrevi isto. Tudo aqui funcionou como documentado na 0.1.0-beta.1, e as listas de atributos da documentação embutida em node_modules/@ui-sdk/react/docs/ batem com o que os componentes renderizaram. A HextaUI está trabalhando em uma versão estilizada dos seus componentes sobre o UI SDK, separada da versão Base UI, e este post é o que aprendi construindo as primeiras peças.




