Blog

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.

  1. Comportamentos@ui-sdk/coreFoco, teclas, seleção, popups, arrastar e soltar.
  2. PrimitivadefinePrimitiveEstado, uma API e slots nomeados em uma única definição.
  3. Partes@ui-sdk/reactCada slot é renderizado com data-slot, atributos de estado e variáveis CSS.
  4. Seus estilosclassNameUtilitá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"

Onde seus estilos se conectam no UI SDK

Seus estilos se conectam às partes por meio de três ganchos:

  • data-slot: toda parte tem um, como data-slot="select-item". Ele nomeia a parte para CSS global e para agentes.
  • Atributos de estado: data-open, data-highlighted, data-selected, data-disabled e assim por diante. A maioria está presente ou ausente. Alguns carregam um valor, como data-side="top" ou data-orientation="vertical".
  • Variáveis CSS: valores que uma classe não consegue expressar, como --anchor-width em um popup ou --rating-fill em 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:

@layer ui-sdk;
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

@theme inline {
  --color-background: var(--background);
  --color-popover: var(--popover);
  --color-accent: var(--accent);
  --color-muted: var(--muted);
  --color-ring: var(--ring);
}

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:

import {
  Select,
  SelectContent,
  SelectItem,
  SelectItemIndicator,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@ui-sdk/react/select"

const roles = [
  { value: "viewer", label: "Viewer" },
  { value: "editor", label: "Editor" },
  { value: "admin", label: "Admin" },
]

function RoleSelect() {
  return (
    <Select defaultValue="editor" items={roles}>
      <SelectLabel className="text-sm font-medium">Role</SelectLabel>
      <SelectTrigger className="mt-2 flex h-9 w-48 items-center justify-between rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 data-open:bg-accent data-placeholder:text-muted-foreground">
        <SelectValue />
      </SelectTrigger>
      <SelectContent
        sideOffset={4}
        className="min-w-(--anchor-width) origin-(--transform-origin) rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 motion-reduce:transition-none"
      >
        {roles.map((role) => (
          <SelectItem
            key={role.value}
            value={role.value}
            className="flex h-8 cursor-default items-center justify-between gap-2 rounded-md px-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
          >
            {role.label}
            <SelectItemIndicator className="text-xs">✓</SelectItemIndicator>
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}
Um select Role aberto abaixo do seu trigger, com Editor marcado e Admin destacado
O select Role aberto no app de teste. Admin tem data-highlighted por causa das setas, e Editor mostra sua marca de seleção.

O que cada classe lê:

  • data-open:bg-accent manté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), sem var().
  • 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 recebem data-selected, e SelectItemIndicator só é 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.

  1. FechadoFora da página
  2. Primeiro framedata-opendata-starting-styleEstilize o estado de onde a animação parte
  3. Abertodata-openTransiciona para seus estilos normais
  4. Fechandodata-closeddata-ending-styleContinua montado até a saída terminar
  5. DesmontadoOu oculto, com keepMounted
Os atributos que um popup do UI SDK carrega ao abrir e fechar

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:

import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@ui-sdk/react/popover"

function ShareProject() {
  return (
    <Popover>
      <PopoverTrigger className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 data-open:bg-accent">
        Share
      </PopoverTrigger>
      <PopoverContent
        side="bottom"
        align="start"
        sideOffset={8}
        className="w-72 origin-(--transform-origin) rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md transition-[opacity,scale,translate] duration-200 ease-out data-starting-style:-translate-y-1 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:duration-100 motion-reduce:transition-none"
      >
        <PopoverTitle className="text-sm font-medium">
          Share project
        </PopoverTitle>
        <PopoverDescription className="mt-1 text-sm text-muted-foreground">
          Anyone with the link can view Atlas.
        </PopoverDescription>
        <PopoverClose className="mt-3 h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground">
          Copy link
        </PopoverClose>
      </PopoverContent>
    </Popover>
  )
}
Um popover Share aberto com um título, uma descrição e um botão Copy link
O popover Share depois da entrada. Ele desce 4px enquanto aumenta de escala e aparece com fade.

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:

import { Rating, RatingItem } from "@ui-sdk/react/rating"

const star =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z'/%3E%3C/svg%3E\")"

function StayRating() {
  return (
    <Rating
      aria-label="How was your stay?"
      step={0.5}
      defaultValue={3.5}
      className="inline-flex gap-1"
    >
      <RatingItem className="size-7 rounded-sm [&_button]:outline-none has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring">
        <span
          aria-hidden="true"
          style={{ maskImage: star, WebkitMaskImage: star }}
          className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-amber-400)_calc(var(--rating-fill)*100%),var(--color-muted)_0)] in-data-highlighted:bg-[linear-gradient(to_right,var(--color-amber-300)_calc(var(--rating-fill)*100%),var(--color-muted)_0)]"
        />
      </RatingItem>
    </Rating>
  )
}
Cinco estrelas com quatro e meia preenchidas em um âmbar mais claro enquanto o ponteiro pré-visualiza uma nota
O ponteiro está sobre a primeira metade da quinta estrela. O preenchimento segue --rating-fill e o âmbar mais claro vem de data-highlighted.

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.

Duas fileiras de estrelas com foco de teclado na quarta estrela. À esquerda, nenhum anel de foco aparece. À direita, um anel arredondado envolve a estrela
O mesmo foco por teclado, duas versões. Esquerda: a máscara fica no RatingItem e corta o contorno. Direita: a máscara fica em um span interno e o anel aparece.

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.

import {
  ColorPicker,
  ColorPickerArea,
  ColorPickerAreaThumb,
  ColorPickerInput,
  ColorPickerSlider,
  ColorPickerSliderThumb,
  ColorPickerSwatch,
} from "@ui-sdk/react/color-picker"
import { Popover, PopoverContent, PopoverTrigger } from "@ui-sdk/react/popover"

const thumb =
  "absolute size-4 -translate-1/2 rounded-full border-2 border-white bg-(--color-picker-color) shadow outline-none ring-1 ring-black/20 focus-visible:ring-3 focus-visible:ring-ring"

function BrandColor() {
  return (
    <ColorPicker aria-label="Brand color" defaultValue="#6366f1">
      <Popover>
        <PopoverTrigger
          aria-label="Brand color"
          className="flex h-9 items-center gap-2 rounded-md border border-input bg-background pr-3 pl-1.5 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
        >
          <ColorPickerSwatch className="size-6 rounded-sm bg-(--color-picker-color) ring-1 ring-black/10 ring-inset" />
          Brand color
        </PopoverTrigger>
        <PopoverContent
          sideOffset={8}
          className="flex w-60 origin-(--transform-origin) flex-col gap-3 rounded-lg border border-border bg-popover p-3 shadow-md transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0 motion-reduce:transition-none"
        >
          <ColorPickerArea className="relative h-36 rounded-md bg-(image:--color-area-background)">
            <ColorPickerAreaThumb className={thumb} />
          </ColorPickerArea>
          <ColorPickerSlider
            channel="hue"
            className="relative h-3 rounded-full bg-(image:--color-slider-background)"
          >
            <ColorPickerSliderThumb className={`${thumb} top-1/2`} />
          </ColorPickerSlider>
          <ColorPickerInput
            aria-label="Hex"
            className="h-8 rounded-md border border-input bg-background px-2 font-mono text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
          />
        </PopoverContent>
      </Popover>
    </ColorPicker>
  )
}
Um botão Brand color com uma amostra índigo e, abaixo dele, um seletor aberto com uma área de saturação, um slider de matiz e um campo hex com #6366f1
O seletor dentro de um Popover. A amostra, os dois thumbs e a área são todos pintados a partir das variáveis CSS do UI SDK.

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. Sem image:, o Tailwind leria a variável como uma cor, e os gradientes não seriam pintados.
  • Os thumbs recebem position: absolute e sua posição em porcentagem como estilos inline. Sua classe só precisa centralizar cada thumb no seu ponto, que é o que -translate-1/2 faz. O thumb do slider também precisa de top-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 ColorPickerAreaCanvas dentro 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:

[data-slot="select-item"] {
  display: flex;
  height: 2rem;
  align-items: center;
  border-radius: calc(var(--radius) - 2px);
  padding-inline: 0.5rem;
}

[data-slot="select-item"][data-highlighted] {
  background: var(--accent);
  color: var(--accent-foreground);
}

[data-slot="select-content"][data-starting-style],
[data-slot="select-content"][data-ending-style] {
  opacity: 0;
  scale: 0.95;
}

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.

AspectoRadixBase UIUI SDK
Estado abertodata-[state=open]:data-open:data-open:
Estilos de entradakeyframes em data-[state=open]data-starting-style:data-starting-style:
Estilos de saídakeyframes 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 popupPortal, ContentPortal, Positioner, PopupSó 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-closed para a saída. Nada anima. Use data-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-height em um filho. A documentação avisa que ela não é herdada. Copie-a no popup, por exemplo [--list-height:var(--available-height)], e leia max-h-(--list-height) mais abaixo.
  • Gradientes a partir de variáveis. Use bg-(image:--name), não bg-(--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.