Sitelet https://docs.dashfoo.com/theming
dashfoo

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:

  1. Import the default skin. @dashfoo/theme ships 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";
  2. Write your own. Select on the data-dashfoo attributes 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 theme layer and the skin rules in the components layer, so your utilities and unlayered CSS override the skin without specificity wars. Import the theme before your own component-layer CSS.
  • An @theme inline block bridges the tokens into Tailwind, generating dashfoo-* utilities for your own markup: bg-/text-/border-/ring- for the 11 color tokens, rounded-dashfoo / rounded-dashfoo-sm, and font-dashfoo. Because the utilities inline var(--dashfoo-*), they follow the [data-dashfoo-theme="dark"] remap automatically, so no dark: variant is needed.
  • Custom CSS should keep reading var(--dashfoo-*), never the --color-dashfoo-* mirrors. The mirrors resolve at :root and ignore subtree dark remaps.

Why attributes instead of class names

Two reasons drive the choice.

  1. Nothing to override. A library that ships default styles forces you into specificity wars or !important to undo them. dashfoo ships none, so your first rule is also the only rule that matches.

  2. Stable selectors. Class names churn across versions and get mangled by CSS-in-JS. The data-dashfoo values are part of the public contract: they change with a major version, not a refactor. Select on them the same way you would select on role or aria-*.

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 valueElementRole
layoutroot <div>The whole frame; the root the skin paints background, color, and font on.
rowrrp <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.
splitterrrp <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.

StateLives onSet 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.

SelectorMatches
[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:

SelectorMatches
[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.
VariableDefault fallbackControls
--dashfoo-snapvar(--dashfoo-primary)Grab-pill color while a snap is engaged.
--dashfoo-snap-transitionflex-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.

VariableDefault fallbackControls
--dashfoo-dock-filloklch(0.556 0 0 / 0.18)Indicator background.
--dashfoo-dock-borderoklch(0.708 0 0 / 0.75)Indicator border color.
--dashfoo-dock-border-width1pxIndicator border width.
--dashfoo-dock-radius6pxIndicator corner radius.
--dashfoo-dock-line-radius2pxInsertion-line corner radius.
--dashfoo-dock-transitionleft 60ms, top 60ms, width 60ms, height 60msPosition 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.

TokenDefault (light)Controls
--dashfoo-backgroundoklch(1 0 0)Layout background
--dashfoo-cardoklch(1 0 0)Tabset + tab-content surface, selected tab
--dashfoo-popoveroklch(1 0 0)Overflow menu + drag-preview surface
--dashfoo-mutedoklch(0.97 0 0)Tab strip + panel-badge background
--dashfoo-accentoklch(0.97 0 0)Hover surface on controls + menu items
--dashfoo-foregroundoklch(0.145 0 0)Primary text, active tab
--dashfoo-muted-foregroundoklch(0.556 0 0)Idle tabs, icons, secondary text
--dashfoo-accent-foregroundoklch(0.205 0 0)Text on hovered controls + menu items
--dashfoo-primaryoklch(0.205 0 0)Active-tab underline, dock-indicator accent
--dashfoo-borderoklch(0.922 0 0)Default borders
--dashfoo-ringoklch(0.708 0 0)Focus rings, rename input, splitter grip
--dashfoo-radius0.625remTabset / menu corner radius
--dashfoo-fontui-sans-serif, system-ui, …Chrome font family
--dashfoo-font-size13pxBase chrome font size
--dashfoo-splitter-size1remResize-handle hit area (see note)
--dashfoo-dock-fillcolor-mix(in oklab, var(--dashfoo-primary) 10%, transparent)Dock-indicator fill while dragging
--dashfoo-dock-bordervar(--dashfoo-ring)Dock-indicator border
--dashfoo-dock-line-radius2pxInsertion-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-size is the default; the model's global.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