Styling with 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.
Basic exampleFocused tabThe focused tab, the selected tab of the active tabset, at full opacity with a frame in the ring colour; every other tab faded to half. Click another tabset and the focus moves; keyboard focus shows as a dashed frame.Open the live exampleState 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.getName()}
</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, so a marker inside the selected tab of the active tabset is:
<Dockable.Tab node={tab} className="group/tab relative …">
{tab.getName()}
<span
aria-hidden="true"
className="absolute inset-x-2 bottom-0 hidden h-0.5 bg-palette-ring in-data-active:group-data-selected/tab:block"
/>
</Dockable.Tab>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: className={tab.isPinned() ? "…" : "…"} 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. - Pad the start of the tab list (
ps-1or more): a tab flush with the tabset's edge cannot take a drop before it (gap 10). - Give tab strips a minimum height (
min-h-7or so), or lower the globaledgeDockMargin: 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 aria-label="Tabs" className="border-t border-palette-line">
{(tab) => <Dockable.Tab node={tab}>{tab.getName()}</Dockable.Tab>}
</Dockable.TabList>
</Dockable.TabSet>The tabset's tabLocation attribute ("top" or "bottom") is the natural switch:
node.getTabLocation() === "bottom".
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 colour utilities work. See
Theming.