Styling with Tailwind
Style every primitive with plain Tailwind utilities, data-* variants and className functions. No plugin, no preset.
The package paints nothing and ships no class names, so Tailwind styles it with ordinary utilities. Everything below is plain Tailwind v4; no plugin or preset is involved.
State through data-* variants
Tailwind v4 turns every attribute into a variant. The primitives expose their state as
data-* attributes, present or absent:
<Dockable.Tab
node={tab}
className="cursor-pointer px-3 text-palette-accent/85 hover:bg-palette-soft data-selected:bg-palette-soft data-selected:text-palette-contrast data-dragging:opacity-40"
>
{tab.label}
</Dockable.Tab>Valued attributes take the bracket form: data-[orientation=vertical]:w-px,
data-[drop-kind=edge]:border-dashed.
State of an ancestor: in-data-* and group
Tab does not know whether its tabset is active (gap 1 in Limitations).
Read it from the ancestor instead. in-data-active: matches when any ancestor has
data-active. A marker under the selected tab of the active tabset needs no element of its own:
draw it as the tab's ::after, shown when the tab is data-selected inside an active tabset:
<Dockable.Tab
node={tab}
className="relative … after:absolute after:inset-x-2 after:bottom-0 after:hidden after:h-0.5 after:bg-palette-ring in-data-active:data-selected:after:block"
>
{tab.label}
</Dockable.Tab>In plain CSS, the same rule is [data-active] [role="tab"][data-selected]::after { … }.
group on the TabSet and group-data-active: on a descendant work the same way. The
focused-tab example is built on this.
className as a function
When a class depends on state that is not an attribute, or on the node, pass a function. It receives the primitive's state:
<Dockable.DropIndicator
className={(state) =>
state.kind === "edge"
? "z-10 rounded-sm border-2 border-dashed border-palette-ring"
: "z-10 rounded-md border-2 border-palette-ring bg-palette-ring/20"
}
/>For the node itself (not a state), close over it: nodes are plain objects with plain fields, so
className={tab.pinned ? "…" : "…"} or one read from your typed data
(tab.data.dirty ? "italic" : "") in the TabList child function works, because the strip
re-renders when the model changes. Prefer data-pinned: when an attribute exists.
Structural rules to remember
- Give the root a size:
min-h-0 flex-1in a sized flex column (Sizing). Put padding on a wrapper, not on the root. - Give tab strips a minimum height (
min-h-7or so), or lowerdefaults.layout.edgeDockMargin: a very thin strip at the top of the layout falls inside the 10px top-edge docking band (gap 11). - Stack the drop indicator:
z-10(panels are portalled after it, gap 12). - Don't fight structural style:
flex-grow,displayor geometry you set with utilities on aRow,TabSetorPanelloses to the primitive's inline style. Size things with weights.
Tab strip at the bottom
TabSet is a structural flex column, so the order of its children is the visual order. A strip
at the bottom is a markup change, not a style:
<Dockable.TabSet node={node}>
<Dockable.TabSetContent />
<Dockable.TabList<Types> aria-label="Tabs" className="border-t border-palette-line">
{(tab) => <Dockable.Tab node={tab}>{tab.label}</Dockable.Tab>}
</Dockable.TabList>
</Dockable.TabSet>Where the strip goes is your app's choice, so keep the switch in your state, or per tabset in the
registry's tabset data (type Types = { tabs: …; tabset: { strip?: "top" | "bottom" } }), read
as node.data?.strip === "bottom" and changed with tabset.configure.
Palettes
The examples use the Fragiola palette roles (bg-palette-base, text-palette-contrast,
border-palette-line, text-palette-accent/85, …) so they work in every theme. That is a
choice of this site, not a requirement: any color utilities work. See
Theming.