Documentation
Guides

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.

Focused tab
Gallery

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-1 in a sized flex column (Sizing). Put padding on a wrapper, not on the root.
  • Give tab strips a minimum height (min-h-7 or so), or lower defaults.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, display or geometry you set with utilities on a Row, TabSet or Panel loses 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.

Tabs at the bottom
Gallery

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.