Blog

Styliser l’UI SDK de Vercel avec Tailwind : data-slot, attributs d’état et animations d’entrée et de sortie

Stylisez le nouvel UI SDK de Vercel avec Tailwind v4 et les tokens shadcn : data-slot, attributs d’état, variables CSS et animations d’entrée et de sortie.

Preet Suthar · · 9 min de lecture

  • React
  • UI SDK
  • Tailwind CSS
  • shadcn/ui

Vercel Labs a publié UI SDK sur npm le 12 novembre 2026. C’est un ensemble de composants accessibles et sans style pour React, Vue, Svelte, Solid et Web Components, construits sur une bibliothèque centrale de comportements. Les composants gèrent le focus, le clavier et ARIA. Leur apparence, c’est votre travail.

Cet article en stylise quatre avec Tailwind CSS v4 et des tokens de thème façon shadcn : un Select, un Popover, un Rating et un Color Picker. Chacun montre un hook de style différent. Chaque extrait ci-dessous tourne dans une app de test construite avec Vite 8, React 19.3, Tailwind CSS 4.3 et @ui-sdk/react et @ui-sdk/core en 0.1.0-beta.1. UI SDK est en bêta, et sa doc indique que les API peuvent encore changer, donc vérifiez la version que vous installez.

Comment fonctionne le style dans UI SDK

UI SDK a trois couches. @ui-sdk/core contient des comportements comme le roving focus, le typeahead et les popups. Une primitive, écrite avec definePrimitive, combine des comportements avec un état et des slots nommés. Un adaptateur de framework comme @ui-sdk/react rend chaque slot sous forme de partie à importer, comme SelectTrigger ou RatingItem.

  1. Comportements@ui-sdk/coreFocus, touches, sélection, popups, glisser-déposer.
  2. PrimitivedefinePrimitiveL’état, une API et des slots nommés dans une seule définition.
  3. Parties@ui-sdk/reactChaque slot est rendu avec data-slot, des attributs d’état et des variables CSS.
  4. Vos stylesclassNameDes utilitaires Tailwind ou du CSS pur qui ciblent ces hooks.

<div data-slot="select-item" data-highlighted data-selected role="option">

className="data-highlighted:bg-accent data-selected:font-medium"

Où vos styles s’attachent dans UI SDK

Vos styles s’attachent aux parties par trois hooks :

  • data-slot : chaque partie en a un, comme data-slot="select-item". Il nomme la partie pour le CSS global et pour les agents.
  • Attributs d’état : data-open, data-highlighted, data-selected, data-disabled, etc. La plupart sont présents ou absents. Certains portent une valeur, comme data-side="top" ou data-orientation="vertical".
  • Variables CSS : des valeurs qu’une classe ne peut pas exprimer, comme --anchor-width sur un popup ou --rating-fill sur une étoile.

className et style peuvent aussi être des fonctions de l’état d’une partie, comme Base UI le permet. Avec Tailwind, c’est rarement nécessaire, car v4 a une variante pour tout attribut data : data-highlighted:bg-accent s’applique tant que data-highlighted est présent.

La doc demande de styliser ces attributs plutôt que des ID générés, qui peuvent changer. Chaque page de composant a une section Styling et une section Data Attributes qui listent exactement ce que chaque partie expose. Cette liste est le contrat.

Configurer Tailwind v4 et la layer ui-sdk

UI SDK a besoin d’un peu de CSS à lui, par exemple pour garder un popup fermé caché. Il ajoute ce CSS à l’exécution dans une cascade layer nommée ui-sdk. Déclarez la layer en premier dans votre feuille de style globale, au-dessus de l’import Tailwind, pour que vos styles l’emportent quel que soit l’endroit où la page insère le CSS d’exécution :

@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);
}

La liste complète des tokens est celle que génère shadcn/ui, avec un bloc .dark qui redéfinit les mêmes noms. Je l’ai raccourcie ici. Rien dans UI SDK ne lit ces tokens. Ils comptent seulement parce que les classes ci-dessous les utilisent, ce qui veut dire qu’un composant UI SDK et un composant shadcn/ui peuvent partager un même thème.

Il n’y a pas de provider à monter ni de feuille de style à importer depuis le package. Si vous utilisez une Content Security Policy sans 'unsafe-inline' pour les styles, lisez la section CSP du guide Styling and Animation, car le CSS d’exécution a besoin d’un nonce ou de feuilles de style construites.

Select : attributs d’état et liste animée

Un Select a un label, un trigger, une valeur, un popup de contenu et des items. Celui-ci montre un sélecteur de rôle avec une coche sur l’item choisi et un court effet d’échelle et de fondu quand la liste s’ouvre et se ferme :

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>
  )
}
Un select Role ouvert sous son trigger, avec Editor coché et Admin en surbrillance
Le select Role ouvert dans l’app de test. Admin porte data-highlighted via les flèches du clavier, et Editor affiche sa coche.

Ce que lit chaque classe :

  • data-open:bg-accent garde le trigger teinté tant que la liste est ouverte.
  • data-placeholder: stylise le trigger tant que rien n’est sélectionné. Avec une valeur par défaut, il ne s’applique jamais ici, mais le garder ne coûte rien.
  • min-w-(--anchor-width) rend la liste au moins aussi large que le trigger. Tailwind v4 écrit une valeur de variable CSS sous la forme (--name), sans var().
  • origin-(--transform-origin) fait grandir la liste depuis le côté où elle s’ouvre. UI SDK définit cette variable sur le contenu.
  • data-highlighted: marque l’item sous le pointeur ou sous le focus clavier. Les items sélectionnés reçoivent data-selected, et SelectItemIndicator n’est rendu que dans un item sélectionné.

Passer items est facultatif, mais la doc le recommande : le trigger peut afficher le label sélectionné avant le rendu de la liste, et le pont agent peut lire les choix pendant que la liste est fermée.

Si vous devez distinguer la surbrillance clavier de la surbrillance pointeur, le contenu porte data-modality="keyboard" ou "pointer". Un anneau de focus visible seulement pour les utilisateurs du clavier s’écrit in-data-[modality=keyboard]:data-highlighted:ring-2 sur l’item.

Le cycle de vie du popup

L’animation ci-dessus utilise deux attributs qui n’existent que pendant une transition. Le contenu reçoit data-starting-style pour sa première frame, pour qu’une transition ait un état de départ. À la fermeture, il reçoit data-ending-style et reste monté jusqu’à la fin de la transition ou de l’animation.

  1. FerméAbsent de la page
  2. Première framedata-opendata-starting-styleStylisez l’état depuis lequel vous animez
  3. Ouvertdata-openPasse en transition vers ses styles normaux
  4. Fermeturedata-closeddata-ending-styleReste monté jusqu’à la fin de la sortie
  5. DémontéOu masqué, avec keepMounted
Les attributs que porte un popup UI SDK à l’ouverture et à la fermeture

Trois détails font que ça fonctionne en pratique.

D’abord, mettez les styles de sortie sur data-ending-style, pas sur data-closed. data-closed décrit l’état, mais la doc est explicite : il ne garde pas l’élément en place pour une sortie.

Ensuite, les popups d’UI SDK utilisent la top layer du navigateur. Le contenu est rendu sur place, comme enfant du composant, et le navigateur le place au-dessus de la page. Dans mon app de test, le contenu ouvert portait popover="manual" et correspondait à :popover-open. Il n’y a pas de partie Portal ni Positioner, et ce n’est pas le z-index qui décide s’il passe au-dessus d’un header sticky.

Enfin, si une bibliothèque d’animation comme Motion a besoin que le contenu fermé reste dans le DOM, activez keepMounted. onOpenChangeComplete s’exécute après la fin de l’ouverture ou de la fermeture, animation comprise.

Popover : une sortie plus rapide que l’entrée

Les entrées et les sorties n’ont pas besoin du même timing. Un popover qui glisse en 200 ms et disparaît en fondu en 100 ms paraît plus rapide qu’un popover qui prend 200 ms dans les deux sens. Comme la sortie a son propre attribut, Tailwind peut lui donner sa propre durée :

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>
  )
}
Un popover Share ouvert avec un titre, une description et un bouton Copy link
Le popover Share après son entrée. Il descend de 4px pendant qu’il grandit et apparaît en fondu.

L’entrée passe de -translate-y-1, scale-95 et opacity-0 à l’état de repos. La sortie se limite à une réduction d’échelle et un fondu, avec data-ending-style:duration-100. motion-reduce:transition-none désactive les deux pour les personnes qui demandent moins de mouvement, et le popover s’ouvre et se ferme quand même.

PopoverTitle et PopoverDescription comptent au-delà de l’apparence. Le contenu est un dialog, et ces parties lui donnent un nom et une description accessibles. Sans titre, donnez au contenu un aria-label.

Rating : stylisez le remplissage, pas les étoiles

Un Rating est un groupe radio dessiné en étoiles. Vous rendez un seul RatingItem et le Rating le répète pour chaque étoile. Chaque item indique dans --rating-fill, de 0 à 1, quelle part de son étoile la note affichée couvre. data-filled marque les étoiles pleines, data-partial marque l’étoile où se termine une demi-note, et data-highlighted marque les étoiles jusqu’à la note que le pointeur prévisualise.

--rating-fill permet d’obtenir des demi-étoiles à partir d’un seul dégradé au lieu de deux icônes :

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>
  )
}
Cinq étoiles dont quatre et demie remplies d’un ambre plus clair pendant que le pointeur prévisualise une note
Le pointeur repose sur la première moitié de la cinquième étoile. Le remplissage suit --rating-fill et l’ambre plus clair vient de data-highlighted.

Avec step={0.5}, chaque demi-étoile est son propre radio, nommé « 3.5 stars », etc. Les radios sont des boutons que le Rating place au-dessus de votre icône avec des styles inline, et l’item reçoit position: relative de la même façon. C’est pour ça que le span peut utiliser absolute inset-0 sans classe supplémentaire.

Le clavier fonctionne sans aucun travail de votre part. Dans mon test, mettre le focus sur l’étoile cochée et appuyer sur la flèche droite a fait passer la note de 3,5 à 4, et le remplissage a suivi.

Gardez le masque hors de l’item focusable

Ma première version mettait le masque d’étoile sur RatingItem lui-même. Le rendu semblait correct jusqu’à ce que j’y arrive avec Tab : un masque CSS coupe tout ce que l’élément peint, contours compris, donc l’anneau de focus disparaissait. Déplacer le masque sur un span interne a réglé le problème. L’item garde son contour arrondi grâce à has-focus-visible:, et [&_button]:outline-none masque l’anneau par défaut du navigateur sur le bouton radio d’une demi-largeur à l’intérieur, qui dessinerait sinon un fin cadre autour d’une demi-étoile.

Deux rangées d’étoiles avec le focus clavier sur la quatrième étoile. À gauche, aucun anneau de focus n’est visible. À droite, un anneau arrondi entoure l’étoile
Même focus clavier, deux versions. À gauche : le masque est sur RatingItem et coupe le contour. À droite : le masque est sur un span interne et l’anneau s’affiche.

Ce n’est pas propre à UI SDK, mais les masques en forme d’icône sont une façon courante de dessiner des notes, donc vérifiez au clavier avant de livrer.

Color Picker : les variables CSS font le dessin

Le Color Picker expose le plus de variables des quatre. Chaque partie écrit la couleur courante dans --color-picker-color. La zone reçoit --color-area-background, un ensemble de dégradés pour ses deux canaux, et chaque slider reçoit --color-slider-background. Vos classes donnent une taille aux parties et utilisent ces variables comme arrière-plans.

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>
  )
}
Un bouton Brand color avec une pastille indigo, et en dessous un sélecteur ouvert avec une zone de saturation, un slider de teinte et un champ hexadécimal affichant #6366f1
Le sélecteur dans un Popover. La pastille, les deux poignées et la zone sont toutes peintes à partir des variables CSS d’UI SDK.

Ce qu’il faut savoir avant de copier ceci :

  • Le Popover se place dans le ColorPicker. Les parties du popup et la pastille du trigger modifient et affichent alors la même couleur.
  • bg-(image:--color-area-background) est l’indication de type de Tailwind v4. Sans image:, Tailwind lirait la variable comme une couleur, et les dégradés ne s’afficheraient pas.
  • Les poignées reçoivent position: absolute et leur position en pourcentage en styles inline. Votre classe n’a qu’à centrer chaque poignée sur son point, ce que fait -translate-1/2. La poignée du slider a aussi besoin de top-1/2, car son style inline ne fixe que la position le long de la piste.
  • La pastille est une image pour les lecteurs d’écran, nommée d’après la couleur. Dans ma version, elle était annoncée « vivid purple » pour #6366f1. Nommez le trigger vous-même, car le texte visible seul ne dit pas forcément à quoi sert la couleur.
  • Pour OKLCH, ajoutez ColorPickerAreaCanvas dans la zone. Un dégradé ne peut pas dessiner le bord de la gamme OKLCH, donc le canvas la peint pixel par pixel.

Styliser par data-slot en CSS pur

Des classes utilitaires sur chaque partie sont une option. L’autre est une feuille de style basée sur data-slot, qui garde le markup court et regroupe l’apparence d’un composant au même endroit :

[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;
}

La doc d’UI SDK stylise aussi ses propres démos en CSS pur. Ça marche parce que data-slot fait partie des attributs qu’une partie garde toujours : la doc le liste à côté des rôles et de tabindex parmi les attributs gérés que vos props ne peuvent pas écraser. Dans un projet façon shadcn/ui, où chaque fichier de composant enveloppe les parties et ajoute ses propres classes, des utilitaires sur les parties sont en général plus simples. Un thème en CSS pur est plus facile à remplacer d’un bloc.

Migrer des classes façon shadcn depuis Radix ou Base UI

Si vos composants viennent de shadcn/ui, la plupart de vos classes se reportent telles quelles. Les différences tiennent à quelques noms d’attributs et à la structure des popups.

PointRadixBase UIUI SDK
État ouvertdata-[state=open]:data-open:data-open:
Styles d’entréekeyframes sur data-[state=open]data-starting-style:data-starting-style:
Styles de sortiekeyframes sur data-[state=closed]data-ending-style:data-ending-style:
Origine de transformation--radix-popover-content-transform-origin--transform-origin--transform-origin
Largeur du trigger--radix-popover-trigger-width--anchor-width--anchor-width
Parties du popupPortal, ContentPortal, Positioner, PopupContent uniquement

En venant de Base UI, les noms d’attributs des popups sont les mêmes, donc les chaînes de classes se reprennent presque telles quelles. Vous supprimez les wrappers Portal et Positioner et déplacez les props de placement comme side, align et sideOffset sur le contenu. En venant de Radix, vous remplacez les variantes data-[state=…] et les animations keyframes par les attributs de début et de fin. Dans les deux cas, vérifiez la liste Data Attributes de chaque composant, car des noms qui semblent partagés peuvent différer. Le Select de Base UI, par exemple, a son propre jeu d’attributs, et celui d’UI SDK aussi.

La comparaison des trois bibliothèques dans leur ensemble, au-delà du style, se trouve dans UI SDK vs Base UI vs Radix.

Ce qui m’a piégé

Une courte liste, pour que vous ne refassiez pas les mêmes erreurs :

  • Styliser data-closed pour une sortie. Rien ne s’anime. Utilisez data-ending-style.
  • Oublier @layer ui-sdk;. Sans cette ligne en premier, le CSS d’exécution peut arriver après vos styles dans la cascade.
  • Masques sur des parties focusables. Ils coupent le contour de focus. Masquez un élément interne.
  • Lire --available-height depuis un enfant. La doc précise que la variable n’est pas héritée. Copiez-la sur le popup, par exemple [--list-height:var(--available-height)], et lisez max-h-(--list-height) plus bas.
  • Dégradés depuis des variables. Utilisez bg-(image:--name), pas bg-(--name).

UI SDK est une bêta, vieille d’un jour au moment où j’écris. Tout ici a fonctionné comme documenté en 0.1.0-beta.1, et les listes d’attributs de la doc embarquée dans node_modules/@ui-sdk/react/docs/ correspondent à ce que les composants ont rendu. HextaUI prépare une version stylisée UI SDK de ses composants, distincte de la version Base UI, et cet article rassemble ce que j’ai appris en construisant les premières pièces.