Theming the headless chrome
The full data-dashfoo attribute + --dashfoo-* token contract, the dark theme, and how to write your own skin.
Import @dashfoo/react/styles.css once before your theme, including when building a custom skin. It supplies the required structural rules and uses CSS custom properties for runtime geometry.
@dashfoo/react renders the dock as plain DOM with zero imposed styling: no
class names, no inline colors, no default border on a tabset. Every structural
element instead carries a data-dashfoo="..." attribute (and the resize
splitters carry data-separator from react-resizable-panels). You style a skin
by selecting on those attributes.
You have two paths:
-
Import the default skin.
@dashfoo/themeships a complete skin over overridable--dashfoo-*tokens, light by default with an opt-in dark variant. Most apps want this. The full token table is in Design tokens and the dark theme below. Tailwind v4 apps import the source entry into their pipeline; everyone else imports the prebuilt CSS (compiled from the same source, no build step):/* Tailwind v4 apps; also generates bg-dashfoo-* etc. utilities */ @import "tailwindcss"; @import "@dashfoo/theme/tailwind.css";// Apps without Tailwind; prebuilt, zero build import "@dashfoo/react/styles.css"; import "@dashfoo/theme/dashfoo.css"; -
Write your own. Select on the
data-dashfooattributes from a regular CSS file. There is no theme provider, no style props, and no runtime config. This guide is the full attribute + state + token contract you'd target.
The examples below are plain CSS. If your app uses Tailwind you can @apply
equivalents. The selectors are the same either way. The canonical worked skin is
packages/theme/src/css/skin.css.
Using the theme with Tailwind v4
The @dashfoo/theme/tailwind.css entry does three things on top of the skin:
- Tokens land in Tailwind's
themelayer and the skin rules in thecomponentslayer, so your utilities and unlayered CSS override the skin without specificity wars. Import the theme before your own component-layer CSS. - An
@theme inlineblock bridges the tokens into Tailwind, generatingdashfoo-*utilities for your own markup:bg-/text-/border-/ring-for the 11 color tokens,rounded-dashfoo/rounded-dashfoo-sm, andfont-dashfoo. Because the utilities inlinevar(--dashfoo-*), they follow the[data-dashfoo-theme="dark"]remap automatically, so nodark:variant is needed. - Custom CSS should keep reading
var(--dashfoo-*), never the--color-dashfoo-*mirrors. The mirrors resolve at:rootand ignore subtree dark remaps.
Why attributes instead of class names
Two reasons drive the choice.
-
Nothing to override. A library that ships default styles forces you into specificity wars or
!importantto undo them. dashfoo ships none, so your first rule is also the only rule that matches. -
Stable selectors. Class names churn across versions and get mangled by CSS-in-JS. The
data-dashfoovalues are part of the public contract: they change with a major version, not a refactor. Select on them the same way you would select onroleoraria-*.
The shipped theme leans into this. It is one flat block of attribute selectors in
plain CSS, every value resolving through a --dashfoo-* token:
[data-dashfoo="layout"] {
background: var(--dashfoo-background);
color: var(--dashfoo-foreground);
font-family: var(--dashfoo-font);
}
[data-dashfoo="tabset"] {
flex: 1 1 0%;
overflow: hidden;
border: 1px solid var(--dashfoo-border);
border-radius: var(--dashfoo-radius);
background: var(--dashfoo-card);
}
/* ...one rule per attribute... */Nothing here is dashfoo-specific machinery. The same selectors work in any CSS dialect.
Attribute reference
Every value of data-dashfoo the renderer emits, what element carries it, and
where it comes from. Read the right-hand column against the source files if you
want to confirm a selector before you write the rule.
data-dashfoo value | Element | Role |
|---|---|---|
layout | root <div> | The whole frame; the root the skin paints background, color, and font on. |
row | rrp <Group> | A horizontal or vertical split container. |
tabset | <div> | One tiled region: a strip of tabs over a content panel. |
tabstrip | <div> | The strip row: tablist plus trailing toolbar. |
tablist | <div> | The scrollable run of tabs, holding the role="tablist" marker. |
tab-item | <span> | One tab's group: the tab button and its close button. |
tab | <button role="tab"> | The selectable tab itself. |
tab-close | <button> | Per-tab close control. |
tab-rename | <input> | Inline rename editor, mounted on double-click. |
tabset-toolbar | <div> | Trailing controls area in the strip. |
tabset-grip | <button> | Drag grip that moves the whole tabset. |
tabset-float | <button> | Floats the tabset into a movable overlay (shown when floatable). |
tabset-maximize | <button> | Maximize / restore toggle. |
tabcontent | <div role="tabpanel"> | The active tab's content panel. |
tab-overflow | <button> | "More tabs" button when the strip overflows. |
tab-overflow-root | <div> | Popper root for the overflow menu. |
tab-overflow-menu | <div> | The overflow dropdown. |
tab-overflow-item | <button> | One tab inside the overflow dropdown. |
panel | <div> | Panel.Root shell. |
panel-header | <div> | Panel.Header row. |
panel-title | <span> | Panel.Title text. |
panel-icon | <span> | Panel.Icon leading slot. |
panel-badge | <span> | Panel.Badge trailing slot. |
panel-body | <div> | Panel.Body scrollable content. |
float | <div> | A floating panel's elevated frame (overlaying the layout). |
float-titlebar | <div> | The float's drag handle / title bar (grip + title + dock control). |
float-grip | <span> | Drag-affordance dots in the title bar (decorative). |
float-title | <span> | The float's window title (its own name); double-click to rename. |
float-rename | <input> | The inline title editor, shown while renaming a float. |
float-minimize | <button> | Collapses the float to a chip. |
float-dock | <button> | Docks the floating panel back into the main layout. |
float-body | <div> | The float's content area. |
float-resize | <div> | An edge/corner resize handle on a float (eight in total). |
float-chip | <button> | A minimized float: a rounded chip you click (or drag) to restore. |
float-chip-label | <span> | The chip's panel title. |
dock-indicator | <div> | The drag overlay: insertion line or drop-zone pane. |
drag-preview | <div> | The chip that follows the pointer while dragging a tab/tabset. |
splitter | rrp <Separator> | The resize handle between panels. Also matchable as [data-separator]. |
A few of these warrant detail.
Tabset structure
A tabset nests four levels deep. The skin styles each separately so the strip, the tabs, and the content panel can have different surfaces.
<div data-dashfoo="tabset">
<div data-dashfoo="tabstrip">
<div data-dashfoo="tablist">
<span role="tablist" aria-owns="…" />
<span data-dashfoo="tab-item">
<button data-dashfoo="tab" role="tab" aria-selected="true">Editor</button>
<button data-dashfoo="tab-close" aria-label="Close Editor">…</button>
</span>
</div>
<div data-dashfoo="tabset-toolbar">
<button data-dashfoo="tabset-maximize" aria-pressed="false">…</button>
</div>
</div>
<div data-dashfoo="tabcontent" role="tabpanel">…</div>
</div>tablist is the scrolling viewport. The empty role="tablist" span inside it
owns the tab buttons by id through aria-owns, so close buttons and rename
inputs stay siblings in the accessibility tree instead of invalid tablist
children. It has no data-dashfoo value: leave it unstyled.
The shipped theme treats the tabset as a card and the strip as its header:
[data-dashfoo="tabset"] {
overflow: hidden;
background: var(--dashfoo-card);
border: 1px solid var(--dashfoo-border);
border-radius: var(--dashfoo-radius);
}
[data-dashfoo="tabstrip"] {
background: var(--dashfoo-muted);
border-bottom: 1px solid var(--dashfoo-border);
}
[data-dashfoo="tabcontent"] {
background: var(--dashfoo-card);
}The tab-rename input
There is no rename mode you opt a class into. When a renamable tab is
double-clicked, the tab button is swapped for an <input data-dashfoo="tab-rename">
in place. Style it as a normal text field; it focuses and selects itself on
mount, commits on Enter or blur, and cancels on Escape.
[data-dashfoo="tab-rename"] {
width: 6rem;
padding: 0.25rem 0.5rem;
background: var(--dashfoo-background);
border: 1px solid var(--dashfoo-ring);
border-radius: 2px;
color: var(--dashfoo-foreground);
outline: none;
}State hooks
State does not arrive as extra data-dashfoo values. It rides on the native
ARIA and data-* attributes the elements already set, so your selectors compose
the structural attribute with the state attribute.
| State | Lives on | Set when |
|---|---|---|
aria-selected="true" | [data-dashfoo="tab"] | The tab is the active one in its tabset |
aria-pressed="true" | [data-dashfoo="tabset-maximize"] | The tabset is maximized |
data-dragging | [data-dashfoo="tab-item"] | This tab is being lifted into the drag preview (dim the source) |
data-dragging-source | [data-dashfoo="tabset"] | This whole tabset is being dragged by its grip |
data-tab-location | [data-dashfoo="tabset"] | "top" (default) or "bottom" strip placement |
tabIndex={0 / -1} | [data-dashfoo="tab"] | Roving tabindex: the selected tab is the one tab stop |
The boolean data-* attributes (data-dragging, data-dragging-source) are
only present when true. They are set to undefined otherwise, so the attribute
is absent rather than ="false". Select on presence:
[data-dashfoo="tab-item"][data-dragging] {
opacity: 0.35;
}
[data-dashfoo="tabset"][data-dragging-source] {
/* the tabset being dragged away by its grip; dim/dash it */
border-style: dashed;
}There is no "drop target" attribute. Where a drop will land is shown by the single
[data-dashfoo="dock-indicator"]overlay, not by a class on the target.
Selected tab
The theme reads selection through aria-selected and adds a weight change plus an
underline pseudo-element. No color is used to carry the state on its own, which
keeps it legible without hue:
[data-dashfoo="tab"] {
cursor: pointer;
background: transparent;
border: 0;
color: var(--dashfoo-muted-foreground);
}
[data-dashfoo="tab"][aria-selected="true"] {
color: var(--dashfoo-foreground);
font-weight: 500;
}
[data-dashfoo="tab-item"]:has([aria-selected="true"]) {
background: var(--dashfoo-card);
}
[data-dashfoo="tab-item"]:has([aria-selected="true"])::after {
content: "";
position: absolute;
inset-inline: 0.5rem;
bottom: -1px;
height: 2px;
border-radius: 9999px;
background: var(--dashfoo-primary);
}The active surface and the underline want to sit on the tab-item wrapper, but
the state lives on the inner tab button. :has() lifts the state up one level
without any extra attribute on the wrapper.
Pressed maximize toggle
The maximize button reports its on/off state through aria-pressed. Give the
pressed toggle a visible surface, e.g. with the hover tokens:
[data-dashfoo="tabset-maximize"][aria-pressed="true"] {
background: var(--dashfoo-accent);
color: var(--dashfoo-accent-foreground);
}The splitter
The resize handle is a react-resizable-panels <Separator>. It carries both
data-dashfoo="splitter" (dashfoo's hook) and data-separator with
aria-orientation set by rrp. Orientation lives on the separator itself, so you
read direction from the attribute rather than from the parent row.
| Selector | Matches |
|---|---|
[data-separator] | Every splitter. |
[data-separator][aria-orientation="vertical"] | Splitter between side-by-side panels (drag left/right). |
[data-separator][aria-orientation="horizontal"] | Splitter between stacked panels (drag up/down). |
[data-separator="disabled"] | Splitter in a non-resizable layout (resizableSplits / editable off). The theme keeps its gutter size but hides the grab pill and resize cursor. |
The handle is invisible by default. The theme gives it a wide hit area with the
right cursor per orientation, then paints a thin pill with a ::before so the
visual grip is narrower than the grab target:
[data-separator] {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
[data-separator][aria-orientation="vertical"] {
width: var(--dashfoo-splitter-size);
cursor: col-resize;
}
[data-separator][aria-orientation="horizontal"] {
height: var(--dashfoo-splitter-size);
cursor: row-resize;
}
[data-separator]::before {
content: "";
background: var(--dashfoo-ring);
border-radius: 9999px;
}
[data-separator][aria-orientation="vertical"]::before {
width: 2px;
height: 2rem;
}
[data-separator][aria-orientation="horizontal"]::before {
width: 2rem;
height: 2px;
}
[data-separator]:hover::before {
background: var(--dashfoo-foreground);
}A 16px grab target around a 2px grip keeps resize easy to hit while the visible line stays quiet. The hover rule raises contrast on the grip, not the whole handle.
Magnetic snapping
When snapping is on and a drag locks the boundary to the grid, the adapter writes two transient attributes that the theme styles without inline color:
| Selector | Matches |
|---|---|
[data-separator][data-dashfoo-snapped="true"] | The splitter while its boundary is locked to a grid line. The grab pill takes --dashfoo-snap and grows. |
[data-dashfoo="row"][data-dashfoo-snapping="true"] | The group during a snap. Its direct [data-panel] children glide onto the line via --dashfoo-snap-transition. |
| Variable | Default fallback | Controls |
|---|---|---|
--dashfoo-snap | var(--dashfoo-primary) | Grab-pill color while a snap is engaged. |
--dashfoo-snap-transition | flex-grow 140ms cubic-bezier(0.4, 0, 0.2, 1) | Panel glide onto the snap line; the theme sets none under reduced motion. |
The transition is scoped to the snapping group only, so free-drag tracking stays 1:1 with the pointer. The boundary glides onto the line and snaps off it instantly.
Dock indicators and the --dashfoo-dock-* vars
The drag overlay is the one place dashfoo writes color inline, and only as a
fallback. While a tab is dragged, a single data-dashfoo="dock-indicator"
element is positioned over the frame. Its position and size are fixed inline (the
library computes them from geometry), but every visual property reads a CSS
custom property with a neutral fallback. Set the variables to own the look
without touching the element's selector.
| Variable | Default fallback | Controls |
|---|---|---|
--dashfoo-dock-fill | oklch(0.556 0 0 / 0.18) | Indicator background. |
--dashfoo-dock-border | oklch(0.708 0 0 / 0.75) | Indicator border color. |
--dashfoo-dock-border-width | 1px | Indicator border width. |
--dashfoo-dock-radius | 6px | Indicator corner radius. |
--dashfoo-dock-line-radius | 2px | Insertion-line corner radius. |
--dashfoo-dock-transition | left 60ms, top 60ms, width 60ms, height 60ms | Position glide; the theme sets none under reduced motion. |
The indicator takes two visual forms from the same element. Dropping onto a tab
strip paints a thin insertion line at the slot boundary, with
--dashfoo-dock-line-radius for its ends; dropping onto the tabset body to
split paints a filled pane covering the zone. Both share the same fill and
border vars, so setting them covers both forms.
The shipped theme derives the two color vars from its semantic tokens, so the indicators follow the light and dark palettes automatically:
:root {
--dashfoo-dock-fill: color-mix(in oklab, var(--dashfoo-primary) 10%, transparent);
--dashfoo-dock-border: var(--dashfoo-ring);
}If you want the indicator to follow your accent color, point these at your own tokens:
:root {
--dashfoo-dock-fill: color-mix(in oklab, var(--accent) 18%, transparent);
--dashfoo-dock-border: var(--accent);
}You can still select [data-dashfoo="dock-indicator"] directly for properties
the vars do not cover, such as box-shadow. The inline left/top/width/height
are owned by the library; leave those alone or the indicator will not track the
pointer.
Focus and hit targets
The renderer wires the keyboard model (roving tabindex on the tablist, arrow keys
to move selection, Home/End to jump). The skin owns the visible focus ring. The
theme draws the tab's ring on the tab-item wrapper as an inset box-shadow, so
it wraps the label plus close button without clipping against the tabset's
overflow: hidden edge; the icon controls take a plain outline:
[data-dashfoo="tab"]:focus-visible {
outline: none;
}
[data-dashfoo="tab-item"]:has([data-dashfoo="tab"]:focus-visible) {
border-radius: 0.375rem;
box-shadow: inset 0 0 0 2px var(--dashfoo-ring);
}
[data-dashfoo="tab-close"]:focus-visible {
outline: 2px solid var(--dashfoo-ring);
}
[data-dashfoo="tabset-maximize"]:focus-visible {
outline: 2px solid var(--dashfoo-ring);
}Mind hit-target sizes on the icon controls. The theme gives tab-close a 16px box
and tabset-maximize a 24px box. The close control sits inside a larger
tab-item hover zone, but on touch you may want to grow both to meet the 44px
target guidance.
Design tokens and the dark theme
If you import @dashfoo/theme, you reskin by remapping the shadcn-style
--dashfoo-* tokens rather than rewriting rules. The tokens are defined in
packages/theme/src/css/tokens.css
and are the intended override surface. The dark variant under
[data-dashfoo-theme="dark"] remaps the same names with the inverted neutral
scale.
| Token | Default (light) | Controls |
|---|---|---|
--dashfoo-background | oklch(1 0 0) | Layout background |
--dashfoo-card | oklch(1 0 0) | Tabset + tab-content surface, selected tab |
--dashfoo-popover | oklch(1 0 0) | Overflow menu + drag-preview surface |
--dashfoo-muted | oklch(0.97 0 0) | Tab strip + panel-badge background |
--dashfoo-accent | oklch(0.97 0 0) | Hover surface on controls + menu items |
--dashfoo-foreground | oklch(0.145 0 0) | Primary text, active tab |
--dashfoo-muted-foreground | oklch(0.556 0 0) | Idle tabs, icons, secondary text |
--dashfoo-accent-foreground | oklch(0.205 0 0) | Text on hovered controls + menu items |
--dashfoo-primary | oklch(0.205 0 0) | Active-tab underline, dock-indicator accent |
--dashfoo-border | oklch(0.922 0 0) | Default borders |
--dashfoo-ring | oklch(0.708 0 0) | Focus rings, rename input, splitter grip |
--dashfoo-radius | 0.625rem | Tabset / menu corner radius |
--dashfoo-font | ui-sans-serif, system-ui, … | Chrome font family |
--dashfoo-font-size | 13px | Base chrome font size |
--dashfoo-splitter-size | 1rem | Resize-handle hit area (see note) |
--dashfoo-dock-fill | color-mix(in oklab, var(--dashfoo-primary) 10%, transparent) | Dock-indicator fill while dragging |
--dashfoo-dock-border | var(--dashfoo-ring) | Dock-indicator border |
--dashfoo-dock-line-radius | 2px | Insertion-line corner radius |
The three optional dock tokens from the indicator table above
(--dashfoo-dock-border-width, --dashfoo-dock-radius,
--dashfoo-dock-transition) are unset by default and complete the set: 21
overridable tokens in total.
Splitter size.
--dashfoo-splitter-sizeis the default; the model'sglobal.splitterSize(a number, in px) overrides it per-layout via an inline CSS var on the layout root.
A reskin is a token remap on :root (or any scope):
:root {
--dashfoo-background: #0b0f17;
--dashfoo-card: #131a26;
--dashfoo-foreground: #e8f0ff;
--dashfoo-radius: 6px;
}Dark is opt-in. The theme ships light by default; set data-dashfoo-theme="dark"
on any ancestor to invert the grayscale for that subtree:
<html data-dashfoo-theme="dark"></html>You can also define your own light/dark token blocks (e.g. under
@media (prefers-color-scheme: dark)) if you're writing the skin from scratch.
Starting your own skin
A minimal skin needs four blocks: the layout surface, the tabset card, the tabs with their selected state, and the splitter. Everything else is refinement.
[data-dashfoo="layout"] {
background: oklch(1 0 0);
color: oklch(0.145 0 0);
padding: 1rem;
}
[data-dashfoo="tabset"] {
border: 1px solid oklch(0.922 0 0);
border-radius: 10px;
background: oklch(1 0 0);
overflow: hidden;
}
[data-dashfoo="tab"] {
border: 0;
background: transparent;
color: oklch(0.556 0 0);
padding: 7px 12px;
cursor: pointer;
}
[data-dashfoo="tab"][aria-selected="true"] {
color: oklch(0.145 0 0);
font-weight: 500;
}
[data-separator][aria-orientation="vertical"] {
width: 8px;
cursor: col-resize;
}
[data-separator][aria-orientation="horizontal"] {
height: 8px;
cursor: row-resize;
}Add the dock variables on :root, then fill in tab-close, tabset-maximize,
focus rings, and hover states as you go. The full reference for what
you can target is the attribute and state tables above.
See also
packages/theme/src/css/skin.cssfor the complete worked skin, andpackages/theme/src/css/tokens.cssfor the token defaults + dark overrides.apps/demo-vite/src/index.cssfor how the demo imports the theme.packages/react/src/components/tabset/tabset-view.tsxfor the tabset markup and ARIA wiring.packages/react/src/components/row-view.tsxfor the splitter (<Separator data-dashfoo="splitter">).packages/react/src/components/drag-provider.tsxfor the dock indicator and the--dashfoo-dock-*fallbacks.