Estilizar el UI SDK de Vercel con Tailwind: data-slot, atributos de estado y animaciones de entrada/salida
Estiliza el nuevo UI SDK de Vercel con Tailwind v4 y tokens de shadcn: data-slot, atributos de estado, variables CSS y animaciones de entrada y salida.
Preet Suthar · · 9 min de lectura
- React
- UI SDK
- Tailwind CSS
- shadcn/ui
Vercel Labs publicó UI SDK en npm el 12 de noviembre de 2026. Es un conjunto de componentes accesibles y sin estilos para React, Vue, Svelte, Solid y Web Components, construidos sobre una biblioteca core de comportamientos. Los componentes se encargan del foco, el teclado y ARIA. Cómo se ven es cosa tuya.
Este post estiliza cuatro de ellos con Tailwind CSS v4 y tokens de tema estilo shadcn: un Select, un Popover, un Rating y un Color Picker. Cada uno muestra un hook de estilos distinto. Todos los snippets de abajo funcionan en una app de prueba hecha con Vite 8, React 19.3, Tailwind CSS 4.3 y @ui-sdk/react y @ui-sdk/core en 0.1.0-beta.1. UI SDK está en beta y su documentación dice que las APIs aún pueden cambiar, así que revisa la versión que instales.
Cómo funcionan los estilos en UI SDK
UI SDK tiene tres capas. @ui-sdk/core contiene comportamientos como el foco itinerante, typeahead y popups. Una primitiva, escrita con definePrimitive, combina comportamientos con estado y slots con nombre. Un adaptador de framework como @ui-sdk/react renderiza cada slot como una parte que importas, como SelectTrigger o RatingItem.
- Comportamientos
@ui-sdk/coreFoco, teclas, selección, popups, drag and drop. - Primitiva
definePrimitiveEstado, una API y slots con nombre en una sola definición. - Partes
@ui-sdk/reactCada slot se renderiza condata-slot, atributos de estado y variables CSS. - Tus estilos
classNameUtilidades de Tailwind o CSS plano que apuntan a esos hooks.
<div data-slot="select-item" data-highlighted data-selected role="option">
className="data-highlighted:bg-accent data-selected:font-medium"
Tus estilos se enganchan a las partes mediante tres hooks:
data-slot: cada parte tiene uno, comodata-slot="select-item". Nombra la parte para el CSS global y para los agentes.- Atributos de estado:
data-open,data-highlighted,data-selected,data-disabled, etc. La mayoría están presentes o ausentes. Algunos llevan un valor, comodata-side="top"odata-orientation="vertical". - Variables CSS: valores que una clase no puede expresar, como
--anchor-widthen un popup o--rating-fillen una estrella.
className y style también pueden ser funciones del estado de una parte, como permite Base UI. Con Tailwind rara vez lo necesitas, porque v4 tiene una variante para cualquier atributo data: data-highlighted:bg-accent se aplica mientras data-highlighted está presente.
La documentación te pide estilizar estos atributos y no los IDs generados, que pueden cambiar. Cada página de componente tiene una sección Styling y una sección Data Attributes que listan exactamente lo que expone cada parte. Esa lista es el contrato.
Configura Tailwind v4 y la capa ui-sdk
UI SDK necesita un poco de CSS propio, por ejemplo para mantener oculto un popup cerrado. Añade ese CSS en runtime en una cascade layer llamada ui-sdk. Declara la capa primero en tu hoja de estilos global, encima del import de Tailwind, para que tus estilos ganen dondequiera que la página inserte el CSS de runtime:
La lista completa de tokens es la que genera shadcn/ui, con un bloque .dark que sobrescribe los mismos nombres. Aquí la he recortado. Nada en UI SDK lee estos tokens. Solo importan porque las clases de abajo los usan, lo que significa que un componente de UI SDK y uno de shadcn/ui pueden compartir un tema.
No hay provider que montar ni hoja de estilos que importar del paquete. Si usas una Content Security Policy sin 'unsafe-inline' para estilos, lee la sección CSP de la guía Styling and Animation, porque el CSS de runtime necesita un nonce o hojas de estilo construidas.
Select: atributos de estado y una lista animada
Un Select tiene un label, un trigger, un valor, un popup de contenido e items. Este muestra un selector de rol con una marca en el item elegido y un breve escalado con fundido cuando la lista se abre y se cierra:

Qué lee cada clase:
data-open:bg-accentmantiene el trigger coloreado mientras la lista está abierta.data-placeholder:estiliza el trigger mientras no hay nada seleccionado. Con un valor por defecto aquí nunca se aplica, pero mantenerlo no cuesta nada.min-w-(--anchor-width)hace que la lista sea al menos tan ancha como el trigger. Tailwind v4 escribe un valor de variable CSS como(--name), sinvar().origin-(--transform-origin)escala la lista desde el lado por el que se abre. UI SDK define esa variable en el contenido.data-highlighted:marca el item bajo el puntero o con el foco de teclado. Los items seleccionados recibendata-selected, ySelectItemIndicatorsolo se renderiza dentro de un item seleccionado.
Pasar items es opcional, pero la documentación lo recomienda: el trigger puede mostrar el label seleccionado antes de que se renderice la lista, y el puente para agentes puede leer las opciones con la lista cerrada.
Si necesitas distinguir el resaltado por teclado del resaltado por puntero, el contenido tiene data-modality="keyboard" o "pointer". Un anillo de foco que solo se ve con teclado es in-data-[modality=keyboard]:data-highlighted:ring-2 en el item.
El ciclo de vida del popup
La animación de arriba usa dos atributos que solo existen durante una transición. El contenido recibe data-starting-style en su primer frame, para que la transición tenga un estado desde el que empezar. Al cerrarse, recibe data-ending-style y sigue montado hasta que termina la transición o la animación.
- CerradoFuera de la página
- Primer frame
data-opendata-starting-styleEstiliza el estado desde el que animas - Abierto
data-openTransiciona a sus estilos normales - Cerrándose
data-closeddata-ending-styleSigue montado hasta que termina la salida - DesmontadoU oculto, con keepMounted
Tres detalles hacen que esto funcione en la práctica.
Primero, pon los estilos de salida en data-ending-style, no en data-closed. data-closed describe el estado, pero la documentación deja claro que no mantiene el elemento en la página para una salida.
Segundo, los popups de UI SDK usan la top layer del navegador. El contenido se renderiza en su sitio, como hijo del componente, y el navegador lo eleva por encima de la página. En mi app de prueba, el contenido abierto llevaba popover="manual" y coincidía con :popover-open. No hay parte Portal ni Positioner, y z-index no decide si queda por encima de una cabecera sticky.
Tercero, si una biblioteca de animación como Motion necesita que el contenido cerrado siga en el DOM, usa keepMounted. onOpenChangeComplete se ejecuta cuando termina la apertura o el cierre, animación incluida.
Popover: una salida más rápida que la entrada
Las entradas y las salidas no necesitan la misma duración. Un popover que entra deslizándose en 200ms y sale con un fundido de 100ms se siente más rápido que uno que tarda 200ms en ambos sentidos. Como la salida tiene su propio atributo, Tailwind puede darle su propia duración:

La entrada va de -translate-y-1, scale-95 y opacity-0 al estado de reposo. La salida solo escala y se desvanece, con data-ending-style:duration-100. motion-reduce:transition-none desactiva ambas para quien pide menos movimiento, y el popover sigue abriéndose y cerrándose.
PopoverTitle y PopoverDescription importan más allá del aspecto. El contenido es un diálogo, y estas partes le dan un nombre y una descripción accesibles. Sin título, ponle un aria-label al contenido.
Rating: estiliza el relleno, no las estrellas
Un Rating es un grupo de radios dibujado como estrellas. Renderizas un RatingItem y el Rating lo repite para cada estrella. Cada item indica en --rating-fill, de 0 a 1, cuánto de su estrella cubre la puntuación mostrada. data-filled marca las estrellas completas, data-partial marca la estrella donde termina una media puntuación y data-highlighted marca las estrellas hasta la puntuación que previsualiza el puntero.
--rating-fill permite que las medias estrellas salgan de un degradado en vez de dos iconos:

Con step={0.5}, cada media estrella es su propio radio, con nombre "3.5 stars", etc. Los radios son botones que el Rating coloca sobre tu icono con estilos inline, y el item recibe position: relative del mismo modo. Por eso el span puede usar absolute inset-0 sin ninguna clase extra.
El teclado funciona sin trabajo por tu parte. En mi prueba, enfocar la estrella marcada y pulsar la flecha derecha movió la puntuación de 3,5 a 4, y el relleno la siguió.
Mantén la máscara fuera del item enfocable
Mi primera versión ponía la máscara de estrella en el propio RatingItem. Se veía bien hasta que entré con Tab: una máscara CSS recorta todo lo que pinta el elemento, contornos incluidos, así que el anillo de foco desaparecía. Mover la máscara a un span interior lo arregló. El item mantiene su contorno redondeado con has-focus-visible:, y [&_button]:outline-none oculta el anillo por defecto del navegador en el botón radio de media anchura que hay dentro, que si no dibuja una caja fina alrededor de media estrella.

Esto no es exclusivo de UI SDK, pero las máscaras con forma de icono son una forma habitual de dibujar valoraciones, así que vale la pena comprobarlo con el teclado antes de publicar.
Color Picker: las variables CSS hacen el dibujo
El Color Picker es el que más variables expone de los cuatro. Cada parte escribe el color actual como --color-picker-color. El área recibe --color-area-background, un conjunto de degradados para sus dos canales, y cada slider recibe --color-slider-background. Tus clases dan tamaño a las partes y usan esas variables como fondos.

Cosas que conviene saber antes de copiar esto:
- El Popover va dentro del ColorPicker. Así, las partes del popup y la muestra del trigger editan y muestran el mismo color.
bg-(image:--color-area-background)es la pista de tipo de Tailwind v4. Sinimage:, Tailwind leería la variable como un color y los degradados no se pintarían.- Los thumbs reciben
position: absolutey su posición en porcentaje como estilos inline. Tu clase solo tiene que centrar cada thumb en su punto, que es lo que hace-translate-1/2. El thumb del slider también necesitatop-1/2, porque su estilo inline solo fija la posición a lo largo del track. - Para los lectores de pantalla, la muestra es una imagen con el nombre del color. En mi build se anunciaba como "vivid purple" para
#6366f1. Ponle tú el nombre al trigger, porque el texto visible por sí solo puede no decir para qué es el color. - Para OKLCH, añade
ColorPickerAreaCanvasdentro del área. Un degradado no puede dibujar el borde de la gama de OKLCH, así que el canvas lo pinta píxel a píxel.
Estilos por data-slot en CSS plano
Las clases de utilidad en cada parte son una opción. La otra es una hoja de estilos basada en data-slot, que mantiene el markup corto y pone el aspecto de un componente en un solo sitio:
La documentación de UI SDK también estiliza sus propias demos con CSS plano. Funciona porque data-slot es uno de los atributos que una parte siempre conserva: la documentación lo lista junto a los roles y tabindex como atributos gestionados que tus props no pueden sobrescribir. En un proyecto estilo shadcn/ui, donde cada archivo de componente envuelve las partes y añade sus propias clases, las utilidades en las partes suelen ser más simples. Un tema en CSS plano es más fácil de cambiar entero.
Pasar clases estilo shadcn desde Radix o Base UI
Si tus componentes vienen de shadcn/ui, la mayoría de tus clases se mantienen. Las diferencias están en unos pocos nombres de atributos y en la estructura del popup.
| Aspecto | Radix | Base UI | UI SDK |
|---|---|---|---|
| Estado abierto | data-[state=open]: | data-open: | data-open: |
| Estilos de entrada | keyframes en data-[state=open] | data-starting-style: | data-starting-style: |
| Estilos de salida | keyframes en data-[state=closed] | data-ending-style: | data-ending-style: |
| Origen de la transformación | --radix-popover-content-transform-origin | --transform-origin | --transform-origin |
| Ancho del trigger | --radix-popover-trigger-width | --anchor-width | --anchor-width |
| Partes del popup | Portal, Content | Portal, Positioner, Popup | Solo Content |
Si vienes de Base UI, los nombres de atributos de los popups son los mismos, así que las cadenas de clases se copian casi tal cual. Eliminas los wrappers Portal y Positioner y mueves las props de posición como side, align y sideOffset al contenido. Si vienes de Radix, sustituyes las variantes data-[state=…] y las animaciones con keyframes por los atributos de inicio y fin. En ambos casos, revisa la lista Data Attributes de cada componente, porque nombres que parecen compartidos pueden variar. El Select de Base UI, por ejemplo, tiene su propio conjunto de atributos, y el de UI SDK también.
La comparación de las tres bibliotecas en conjunto, más allá de los estilos, está en UI SDK vs Base UI vs Radix.
Lo que me hizo tropezar
Una lista corta, para que no te pase a ti:
- Estilizar
data-closedpara una salida. No se anima nada. Usadata-ending-style. - Olvidar
@layer ui-sdk;. Si no va primero, el CSS de runtime puede quedar después de tus estilos en la cascada. - Máscaras en partes enfocables. Recortan el contorno de foco. Aplica la máscara a un elemento interior.
- Leer
--available-heightdesde un hijo. La documentación indica que no se hereda. Cópiala en el popup, por ejemplo[--list-height:var(--available-height)], y leemax-h-(--list-height)más abajo. - Degradados desde variables. Usa
bg-(image:--name), nobg-(--name).
UI SDK es una beta con un día de vida cuando escribo esto. Todo lo de aquí funcionó como está documentado en 0.1.0-beta.1, y las listas de atributos de la documentación incluida en node_modules/@ui-sdk/react/docs/ coinciden con lo que renderizaron los componentes. HextaUI está trabajando en una variante con estilos de sus componentes sobre UI SDK, separada de la de Base UI, y este post recoge lo que aprendí construyendo las primeras piezas.




