The styling contract
Every public part accepts your className, style, ref, event handlers, native props and data-* attributes directly. There are no parallel props such as contentClassName or triggerClassName.
uiify owns the identity and state attributes. They are styling hooks: change state through props or native HTML behavior, never by writing data-state. The --uiify-* namespace is reserved for geometry, animation and per-instance values.
<Popover.Root>
<Popover.Trigger className="toolbar-trigger">Open</Popover.Trigger>
<Popover.Content className="toolbar-popover" data-layout="compact">
Actions
</Popover.Content>
</Popover.Root>Naming
| Hook | Purpose | Example |
|---|---|---|
| Identity markers | Stable component identity, owned by uiify | data-uiify-popover |
| Anatomy markers | Public compound part, owned by uiify | data-part="content" |
| State attributes | Read-only state mirror for selectors | data-state="open" |
| Custom properties | Dynamic geometry and per-instance values | --uiify-anchor-x |
Each state-bearing root has one presence-valued data-uiify-<component> marker. Each compound part has one kebab-case data-part value, such as trigger, content, item or label. Consumer props cannot override these values.
Plain CSS and Tailwind
[data-uiify-popover][data-part="content"] {
min-inline-size: 12rem;
}
[data-uiify-popover][data-part="content"][data-state="open"] {
opacity: 1;
}
/* Tailwind uses the same attributes. */
<div className="data-[state=open]:opacity-100" />Polymorphism
as and render replace the rendered element. A custom renderer must forward the props and ref it receives to the DOM element, or markers, state and styling stop working.
behavior.css is required
This stylesheet holds the rules that make state work: popover positioning, ::details-content sizing, :has() propagation, hidden-input placement and @starting-style transitions. It has no colors, fonts or spacing.
import "@gunkin/uiify/components/behavior.css";Without it, components render but their state does not work. Import it once, anywhere in your app.
State is an attribute, not a class
Components expose state as data-* attributes on the element that owns it, so CSS can select it without JavaScript or class names. Each component page lists the attributes it emits.
.my-trigger[data-state="open"] { … }
.my-item[data-disabled] { … }
/* Native state works the same way, because the state IS native */
.my-panel:popover-open { … }
details[open] > summary { … }Variants are data-* attributes too, not container style queries, because a container cannot query itself.
Values inline, rules in a stylesheet
Per-instance values arrive as inline custom properties; every rule lives in a static stylesheet. Inline styles cannot express pseudo-classes, pseudo-elements, container queries or @starting-style, and the state model depends on all of them.
Inline properties stream with the HTML without hydration mismatches, and one stylesheet is parsed once and cached. CSS-in-JS instead injects <style> chunks into every boundary and does not cache across requests.
Three ways to style
Your own CSS. Select the element and its data-* attributes; there are no default styles to override.
Tailwind. The same attributes work as variants, for example data-[state=open]:rotate-180. Site previews switch to the Tailwind skin from the Style setting in the header.
@gunkin/uiify/styles. An optional plain-CSS layer with OKLCH token scales, light and dark themes and a skin for every component — a starting point, not a requirement.
import "@gunkin/uiify/styles/reset";
import "@gunkin/uiify/styles";Cascade order
Everything uiify ships sits in a named layer below yours:
@layer ui.reset, ui.behavior, ui.tokens, ui.theme, app;Your layer always wins: overrides need no !important or specificity tricks, and they survive library refactors.
Each component page lists its attributes and CSS variables, with copy-paste CSS for both skins.