Vercels UI SDK mit Tailwind stylen: data-slot, State-Attribute und Enter- und Exit-Animationen
Style Vercels neues UI SDK mit Tailwind v4 und shadcn-Tokens: data-slot, State-Attribute, CSS-Variablen und Enter- und Exit-Animationen an vier Komponenten.
Preet Suthar · · 9 Min. Lesezeit
- React
- UI SDK
- Tailwind CSS
- shadcn/ui
Vercel Labs hat UI SDK am 12. November 2026 auf npm veröffentlicht. Es ist eine Sammlung ungestylter, barrierefreier Komponenten für React, Vue, Svelte, Solid und Web Components, gebaut auf einer Core-Bibliothek aus Behaviors. Die Komponenten kümmern sich um Fokus, Tastatur und ARIA. Wie sie aussehen, ist deine Sache.
Dieser Post stylt vier davon mit Tailwind CSS v4 und Theme-Tokens im shadcn-Stil: ein Select, ein Popover, ein Rating und einen Color Picker. Jede zeigt einen anderen Styling-Hook. Jedes Snippet unten läuft in einer Test-App mit Vite 8, React 19.3, Tailwind CSS 4.3 sowie @ui-sdk/react und @ui-sdk/core in 0.1.0-beta.1. UI SDK ist in der Beta, und laut Doku können sich APIs noch ändern, also prüfe die Version, die du installierst.
Wie Styling in UI SDK funktioniert
UI SDK hat drei Schichten. @ui-sdk/core enthält Behaviors wie Roving Focus, Typeahead und Popups. Ein Primitive, geschrieben mit definePrimitive, verbindet Behaviors mit State und benannten Slots. Ein Framework-Adapter wie @ui-sdk/react rendert jeden Slot als Part, den du importierst, etwa SelectTrigger oder RatingItem.
- Behaviors
@ui-sdk/coreFokus, Tasten, Auswahl, Popups, Drag and Drop. - Primitive
definePrimitiveState, eine API und benannte Slots in einer Definition. - Teile
@ui-sdk/reactJeder Slot rendert mitdata-slot, State-Attributen und CSS-Variablen. - Deine Styles
classNameTailwind-Utilities oder reines CSS, die auf diese Hooks zielen.
<div data-slot="select-item" data-highlighted data-selected role="option">
className="data-highlighted:bg-accent data-selected:font-medium"
Deine Styles setzen über drei Hooks an den Parts an:
data-slot: Jeder Part hat eins, etwadata-slot="select-item". Es benennt den Part für globales CSS und für Agenten.- State-Attribute:
data-open,data-highlighted,data-selected,data-disabledund so weiter. Die meisten sind einfach vorhanden oder nicht. Manche tragen einen Wert, etwadata-side="top"oderdata-orientation="vertical". - CSS-Variablen: Werte, die eine Klasse nicht ausdrücken kann, etwa
--anchor-widthan einem Popup oder--rating-fillan einem Stern.
className und style können auch Funktionen des Zustands eines Parts sein, so wie bei Base UI. Mit Tailwind brauchst du das selten, weil v4 für jedes Data-Attribut eine Variante hat: data-highlighted:bg-accent greift, solange data-highlighted vorhanden ist.
Die Doku bittet dich, diese Attribute zu stylen statt generierter IDs, die sich ändern können. Jede Komponentenseite hat einen Abschnitt Styling und einen Abschnitt Data Attributes, die genau auflisten, was jeder Part bereitstellt. Diese Liste ist der Vertrag.
Tailwind v4 und den ui-sdk-Layer einrichten
UI SDK braucht ein wenig eigenes CSS, zum Beispiel um ein geschlossenes Popup versteckt zu halten. Dieses CSS fügt es zur Laufzeit in einem Cascade Layer namens ui-sdk ein. Deklariere den Layer zuerst in deinem globalen Stylesheet, über dem Tailwind-Import, damit deine Styles gewinnen, egal wo die Seite das Runtime-CSS einfügt:
Die vollständige Token-Liste ist die, die shadcn/ui generiert, mit einem .dark-Block, der dieselben Namen überschreibt. Ich habe sie hier gekürzt. Nichts in UI SDK liest diese Tokens. Sie zählen nur, weil die Klassen unten sie nutzen, und das heißt, eine UI-SDK-Komponente und eine shadcn/ui-Komponente können sich ein Theme teilen.
Es gibt keinen Provider zum Mounten und kein Stylesheet zum Importieren aus dem Paket. Wenn du eine Content Security Policy ohne 'unsafe-inline' für Styles nutzt, lies den CSP-Abschnitt im Guide Styling and Animation, denn das Runtime-CSS braucht eine Nonce oder Constructed Stylesheets.
Select: State-Attribute und eine animierte Liste
Ein Select hat ein Label, einen Trigger, einen Wert, ein Content-Popup und Items. Dieses hier zeigt eine Rollenauswahl mit einem Haken am gewählten Item und einem kurzen Skalieren und Einblenden beim Öffnen und Schließen der Liste:

Was jede Klasse liest:
data-open:bg-accenthält den Trigger eingefärbt, solange die Liste offen ist.data-placeholder:stylt den Trigger, solange nichts gewählt ist. Mit einem Standardwert greift es hier nie, aber es kostet nichts, es zu behalten.min-w-(--anchor-width)macht die Liste mindestens so breit wie den Trigger. Tailwind v4 schreibt einen CSS-Variablenwert als(--name), ohnevar().origin-(--transform-origin)skaliert die Liste von der Seite aus, an der sie sich öffnet. UI SDK setzt diese Variable am Content.data-highlighted:markiert das Item unter dem Zeiger oder dem Tastaturfokus. Gewählte Items bekommendata-selected, undSelectItemIndicatorrendert nur in einem gewählten Item.
items zu übergeben ist optional, aber die Doku empfiehlt es: Der Trigger kann das gewählte Label zeigen, bevor die Liste rendert, und die Agent-Bridge kann die Optionen lesen, während die Liste geschlossen ist.
Wenn du Tastatur-Hervorhebung von Zeiger-Hervorhebung unterscheiden musst: Der Content hat data-modality="keyboard" oder "pointer". Ein Fokusring, der nur für Tastaturnutzer erscheint, ist in-data-[modality=keyboard]:data-highlighted:ring-2 am Item.
Der Lebenszyklus eines Popups
Die Animation oben nutzt zwei Attribute, die es nur während einer Transition gibt. Der Content bekommt für seinen ersten Frame data-starting-style, damit eine Transition einen Startzustand hat. Beim Schließen bekommt er data-ending-style und bleibt gemountet, bis die Transition oder Animation fertig ist.
- GeschlossenNicht in der Seite
- Erster Frame
data-opendata-starting-styleStyle den Zustand, von dem aus du animierst - Offen
data-openGeht per Transition in seine normalen Styles über - Schließt
data-closeddata-ending-styleBleibt gemountet, bis der Exit fertig ist - UngemountetOder versteckt, mit keepMounted
Drei Details sorgen dafür, dass das in der Praxis funktioniert.
Erstens: Leg Exit-Styles auf data-ending-style, nicht auf data-closed. data-closed beschreibt den Zustand, aber die Doku sagt klar, dass es das Element für einen Exit nicht im DOM hält.
Zweitens: Popups in UI SDK nutzen den Top Layer des Browsers. Der Content rendert an Ort und Stelle, als Kind der Komponente, und der Browser hebt ihn über die Seite. In meiner Test-App trug der offene Content popover="manual" und passte auf :popover-open. Es gibt keinen Portal- oder Positioner-Part, und z-index entscheidet nicht darüber, ob er über einem Sticky Header liegt.
Drittens: Wenn eine Animationsbibliothek wie Motion geschlossenen Content im DOM braucht, setz keepMounted. onOpenChangeComplete läuft, nachdem das Öffnen oder Schließen fertig ist, einschließlich der Animation.
Popover: ein schnellerer Exit als Enter
Ein- und Ausblenden brauchen nicht dasselbe Timing. Ein Popover, das über 200ms hereingleitet und über 100ms ausblendet, wirkt schneller als eins, das in beide Richtungen 200ms braucht. Weil der Exit sein eigenes Attribut hat, kann Tailwind ihm eine eigene Dauer geben:

Das Enter geht von -translate-y-1, scale-95 und opacity-0 in den Ruhezustand. Der Exit skaliert und blendet nur aus, mit data-ending-style:duration-100. motion-reduce:transition-none schaltet beides für Leute ab, die weniger Bewegung wollen, und das Popover öffnet und schließt trotzdem.
PopoverTitle und PopoverDescription zählen nicht nur fürs Aussehen. Der Content ist ein Dialog, und diese Parts geben ihm einen barrierefreien Namen und eine Beschreibung. Ohne Titel gib dem Content ein aria-label.
Rating: style die Füllung, nicht die Sterne
Ein Rating ist eine Radio-Gruppe, die als Sterne gezeichnet wird. Du renderst ein RatingItem, und das Rating wiederholt es für jeden Stern. Jedes Item meldet in --rating-fill von 0 bis 1, wie viel seines Sterns die angezeigte Bewertung abdeckt. data-filled markiert ganze Sterne, data-partial den Stern, in dem eine halbe Bewertung endet, und data-highlighted die Sterne bis zu der Bewertung, die der Zeiger als Vorschau zeigt.
Mit --rating-fill entstehen halbe Sterne aus einem Verlauf statt aus zwei Icons:

Mit step={0.5} ist jeder halbe Stern ein eigenes Radio, benannt „3.5 stars“ und so weiter. Die Radios sind Buttons, die das Rating per Inline-Styles über dein Icon legt, und das Item bekommt auf dieselbe Weise position: relative. Deshalb kann der Span absolute inset-0 ohne zusätzliche Klasse nutzen.
Die Tastatur funktioniert, ohne dass du etwas tun musst. Den angehakten Stern zu fokussieren und die Pfeiltaste nach rechts zu drücken, hob die Bewertung in meinem Test von 3,5 auf 4, und die Füllung folgte.
Halte die Maske vom fokussierbaren Item fern
Meine erste Version legte die Sternmaske direkt auf RatingItem. Das sah richtig aus, bis ich per Tab hineinging: Eine CSS-Maske schneidet alles ab, was das Element zeichnet, auch Outlines, also verschwand der Fokusring. Die Maske auf einen inneren Span zu verschieben, hat es behoben. Das Item behält seine abgerundete Outline über has-focus-visible:, und [&_button]:outline-none blendet den Standardring des Browsers am halb breiten Radio-Button darin aus, der sonst einen dünnen Kasten um einen halben Stern zeichnet.

Das ist nicht spezifisch für UI SDK, aber Masken in Icon-Form sind ein üblicher Weg, Bewertungen zu zeichnen, also lohnt es sich, das vor dem Release mit der Tastatur zu prüfen.
Color Picker: CSS-Variablen übernehmen das Zeichnen
Der Color Picker stellt von den vier die meisten Variablen bereit. Jeder Part schreibt die aktuelle Farbe als --color-picker-color. Die Fläche bekommt --color-area-background, eine Reihe von Verläufen für ihre zwei Kanäle, und jeder Slider bekommt --color-slider-background. Deine Klassen geben den Parts eine Größe und nutzen diese Variablen als Hintergrund.

Was du wissen solltest, bevor du das kopierst:
- Das Popover kommt in den ColorPicker. Die Parts im Popup und das Farbfeld im Trigger bearbeiten und zeigen dann dieselbe Farbe.
bg-(image:--color-area-background)ist der Typhinweis von Tailwind v4. Ohneimage:würde Tailwind die Variable als Farbe lesen, und Verläufe würden nicht gemalt.- Die Thumbs bekommen
position: absoluteund ihre Position in Prozent als Inline-Styles. Deine Klasse muss jeden Thumb nur auf seinem Punkt zentrieren, und genau das macht-translate-1/2. Der Slider-Thumb braucht außerdemtop-1/2, weil sein Inline-Style nur die Position entlang der Spur setzt. - Das Farbfeld ist für Screenreader ein Bild, benannt nach der Farbe. In meinem Build wurde es für
#6366f1als „vivid purple“ angesagt. Benenne den Trigger selbst, denn der sichtbare Text allein sagt vielleicht nicht, wofür die Farbe ist. - Für OKLCH fügst du
ColorPickerAreaCanvasin die Fläche ein. Ein Verlauf kann die Gamut-Grenze von OKLCH nicht zeichnen, deshalb malt das Canvas sie Pixel für Pixel.
Styling über data-slot in reinem CSS
Utility-Klassen an jedem Part sind eine Möglichkeit. Die andere ist ein Stylesheet, das auf data-slot basiert. Das hält das Markup kurz und bündelt das Aussehen einer Komponente an einer Stelle:
Die UI-SDK-Doku stylt ihre eigenen Demos ebenfalls mit reinem CSS. Das funktioniert, weil data-slot zu den Attributen gehört, die ein Part immer behält: Die Doku führt es neben Rollen und tabindex als verwaltete Attribute, die deine Props nicht überschreiben können. In einem Projekt im shadcn/ui-Stil, in dem jede Komponentendatei die Parts umschließt und eigene Klassen hinzufügt, sind Utilities an den Parts meist einfacher. Ein Theme in reinem CSS lässt sich leichter komplett austauschen.
Klassen im shadcn-Stil von Radix oder Base UI übernehmen
Wenn deine Komponenten von shadcn/ui kommen, lassen sich die meisten Klassen übernehmen. Die Unterschiede liegen in ein paar Attributnamen und in der Popup-Struktur.
| Thema | Radix | Base UI | UI SDK |
|---|---|---|---|
| Offen-Zustand | data-[state=open]: | data-open: | data-open: |
| Enter-Styles | Keyframes auf data-[state=open] | data-starting-style: | data-starting-style: |
| Exit-Styles | Keyframes auf data-[state=closed] | data-ending-style: | data-ending-style: |
| Transform-Origin | --radix-popover-content-transform-origin | --transform-origin | --transform-origin |
| Trigger-Breite | --radix-popover-trigger-width | --anchor-width | --anchor-width |
| Popup-Parts | Portal, Content | Portal, Positioner, Popup | Nur Content |
Wenn du von Base UI kommst, sind die Attributnamen für Popups dieselben, die Klassen-Strings lassen sich also größtenteils übernehmen. Du löschst die Wrapper Portal und Positioner und verschiebst Platzierungs-Props wie side, align und sideOffset auf den Content. Wenn du von Radix kommst, ersetzt du data-[state=…]-Varianten und Keyframe-Animationen durch die Starting- und Ending-Attribute. So oder so: Prüfe die Liste der Data Attributes jeder Komponente, denn Namen, die gleich aussehen, können sich unterscheiden. Das Select von Base UI hat zum Beispiel seinen eigenen Attributsatz, und das von UI SDK auch.
Den Vergleich der drei Bibliotheken als Ganzes, über das Styling hinaus, findest du in UI SDK vs Base UI vs Radix.
Woran ich hängen geblieben bin
Eine kurze Liste, damit du es nicht wiederholst:
data-closedfür einen Exit stylen. Nichts wird animiert. Nutzedata-ending-style.@layer ui-sdk;vergessen. Steht es nicht zuerst, kann das Runtime-CSS in der Kaskade nach deinen Styles landen.- Masken auf fokussierbaren Parts. Sie schneiden den Fokusrahmen ab. Maskiere ein inneres Element.
--available-heightin einem Kind lesen. Laut Doku wird es nicht vererbt. Kopiere es am Popup, zum Beispiel[--list-height:var(--available-height)], und lies weiter untenmax-h-(--list-height).- Verläufe aus Variablen. Nutze
bg-(image:--name), nichtbg-(--name).
UI SDK ist eine Beta, zum Zeitpunkt des Schreibens einen Tag alt. Alles hier hat in 0.1.0-beta.1 wie dokumentiert funktioniert, und die Attributlisten in der mitgelieferten Doku unter node_modules/@ui-sdk/react/docs/ stimmen mit dem überein, was die Komponenten gerendert haben. HextaUI arbeitet an einer gestylten UI-SDK-Variante seiner Komponenten, getrennt von der Base-UI-Variante, und dieser Post ist das, was ich beim Bauen der ersten Teile gelernt habe.




