DEV Community

Cover image for Headless Components Are Useless Without a Styling Strategy
venkatesh m
venkatesh m

Posted on

Headless Components Are Useless Without a Styling Strategy

Most headless component libraries stop at behavior. They give you a Dialog that traps focus and a Tabs component with roving tabindex, then tell you to "bring your own styles."

That's where most side projects stop too. The headless primitives work. The README says "unstyled." And the components sit in a repo that nobody wants to actually use — because now the consumer owns all the CSS, and without a token system driving it, the visual consistency depends entirely on discipline.

The gap isn't behavior. It's the bridge between behavior and presentation. How do you add a visual layer on top of headless primitives without welding the two together? How do you let the token pipeline drive every color, every spacing value, every border radius — while keeping the styled layer thin enough that replacing it doesn't mean rewriting the component?

That's what Phase 3 of flintwork solves. And the answer turned out to be surprisingly small.

The Two-Layer Architecture

Every component in flintwork exists as two things:

A headless primitive that owns behavior — state management, keyboard interactions, focus trapping, ARIA attributes. Zero styling. Outputs plain HTML with the right attributes and event handlers. This is what Articles 1 and 2 covered.

A styled wrapper that owns presentation — colors, spacing, typography, borders, shadows. Consumes the primitive, adds a single data attribute, and ships a CSS file that targets that attribute. The wrapper is so thin it's almost trivial. That's the point.

// Headless — behavior only, no opinions
import { Button } from 'flintwork/primitives';

// Styled — behavior + token-driven visuals
import { Button } from 'flintwork';
import 'flintwork/styles/button.css';
Enter fullscreen mode Exit fullscreen mode

The consumer picks which layer they want. Teams that need full visual control use the primitive and write their own CSS. Teams that want a working design system out of the box use the styled export. Both share the same accessibility and keyboard behavior because that lives in the primitive, not the styles.

The exports field in package.json maps each import path to the correct file — TypeScript types, ESM, CJS, and CSS all resolved per path. flintwork/styles/button points to a single CSS file. A consumer who only uses Button doesn't load Dialog or Tabs CSS. Tree-shaking at the CSS level, not just the JavaScript level.

Why Not CSS-in-JS, CSS Modules, or Tailwind?

Every CSS strategy has a runtime or tooling cost. CSS-in-JS adds JavaScript to your bundle that runs on every render. CSS Modules require a bundler plugin — your consumer's build pipeline has to support them. Tailwind needs its own build step and a config file.

The styled layer uses plain CSS files with data attribute selectors. No runtime. No bundler plugin. No config. The consumer imports a .css file and the styles apply. If their bundler can handle CSS imports (every modern bundler can), it works.

The selectors target data attributes that the headless primitive already emits:

[data-fw-button] { /* base structure */ }
[data-fw-button][data-variant="primary"] { /* primary colors */ }
[data-fw-button][data-size="md"] { /* medium sizing */ }
[data-fw-button]:hover { /* hover state */ }
Enter fullscreen mode Exit fullscreen mode

The primitives emit data-variant, data-size, data-state, data-loading, aria-disabled — all the state the CSS needs to respond to. The styled layer doesn't inject any JavaScript. It reads what the primitive already outputs.

In DevTools, you see data-variant="primary" on the element and [data-variant="primary"] in the stylesheet. Self-documenting. No generated class names, no hash suffixes, no layer of indirection between what you see in the DOM and what you see in the CSS.

The CSS Pattern That Killed Repetition

A button has four variants (primary, secondary, danger, ghost), three sizes (sm, md, lg), and multiple states (hover, active, focus, disabled, loading). That's potentially dozens of selector blocks, each repeating the same property declarations with different values.

The first approach I tried was direct:

[data-variant="primary"] {
  background: var(--fw-button-primary-bg);
  color: var(--fw-button-primary-text);
  border-color: var(--fw-button-primary-border);
}

[data-variant="primary"]:hover {
  background: var(--fw-button-primary-bgHover);
  color: var(--fw-button-primary-text);
  border-color: var(--fw-button-primary-border);
}

[data-variant="secondary"] {
  background: var(--fw-button-secondary-bg);
  color: var(--fw-button-secondary-text);
  border-color: var(--fw-button-secondary-border);
}

/* repeat for every variant × every state */
Enter fullscreen mode Exit fullscreen mode

It works. But the background, color, and border-color declarations repeat in every block. Add a fifth variant and you're writing the same three lines again. Add a property — say box-shadow — and you're editing every variant block.

The pattern that fixed this: declare the structure once, let variants swap values.

/* Structure — declared once, never repeated */
[data-fw-button] {
  background: var(--_bg);
  color: var(--_text);
  border: 1px solid var(--_border);
  padding: var(--_py) var(--_px);
  font-size: var(--_fs);
}

/* Variants only assign values to the intermediates */
[data-variant="primary"] {
  --_bg: var(--fw-button-primary-bg);
  --_text: var(--fw-button-primary-text);
  --_border: var(--fw-button-primary-border);
}

[data-variant="primary"]:hover {
  --_bg: var(--fw-button-primary-bgHover);
}

[data-variant="secondary"] {
  --_bg: var(--fw-button-secondary-bg);
  --_text: var(--fw-button-secondary-text);
  --_border: var(--fw-button-secondary-border);
}
Enter fullscreen mode Exit fullscreen mode

The --_ prefix marks these as component-private intermediate variables. They're not part of the token system — consumers never reference --_bg directly. They're internal wiring that connects the token pipeline to the CSS declarations.

The base selector says what properties a button uses. The variant selectors say which token values fill those properties. Adding a fifth variant is one block of variable assignments — no structural changes to the base.

The hover block for primary is a single line. It only overrides --_bg because the text and border don't change on hover. In the direct approach, you'd repeat all three properties just to change one.

This is the same principle as the token architecture itself: separate what something is from how it's configured.

The Styled Wrapper Is Almost Nothing

Here's the entire styled Button component:

export const Button = forwardRef(
  <T extends ElementType = 'button'>(
    props: StyledButtonProps<T>,
    ref: React.Ref<Element>,
  ) => {
    return (
      <ButtonPrimitive
        ref={ref}
        data-fw-button=""
        {...props}
      />
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

One attribute: data-fw-button="". That's the only thing the styled layer adds. Everything else — the variant prop, the size prop, loading state, disabled state, aria attributes, keyboard handlers, polymorphic as prop — passes straight through to the primitive.

The CSS file targets [data-fw-button]. Without the CSS import, this component renders identically to the headless primitive. The attribute is inert until the stylesheet loads.

Dialog follows the same pattern but only wraps the sub-components that produce visible DOM:

Dialog          → pass-through (no DOM, pure context)
Dialog.Trigger  → pass-through (renders consumer's child)
Dialog.Portal   → pass-through (createPortal wrapper)
Dialog.Overlay  → adds data-fw-dialog-overlay
Dialog.Content  → adds data-fw-dialog-content + size prop
Dialog.Title    → adds data-fw-dialog-title
Dialog.Description → adds data-fw-dialog-description
Dialog.Close    → pass-through (renders consumer's child)
Enter fullscreen mode Exit fullscreen mode

Five of eight sub-components pass through untouched. The styled layer only touches what has pixels on screen.

Tokens Drive Everything

Every value in the CSS comes from the token pipeline. Not a single hardcoded color, spacing value, or font size in any stylesheet.

The resolution chain from the first article is what makes this work:

button.json:     button.primary.bg = {color.interactive.default}
light.json:      color.interactive.default = {color.blue.500}
colors.json:     color.blue.500 = #217CF5

→ CSS output:    --fw-button-primary-bg: #217CF5
→ Button CSS:    --_bg: var(--fw-button-primary-bg)
→ Rendered:      background: #217CF5
Enter fullscreen mode Exit fullscreen mode

Switch to dark mode — swap data-theme="dark" on the root element — and the semantic layer remaps color.interactive.default to color.blue.400 (#4C97FF). The component CSS doesn't change. The token pipeline resolves the new chain. Every button on the page updates.

The styled layer doesn't know about themes. It doesn't know about light or dark. It references token custom properties, and the token pipeline handles the rest. That separation is what makes theme switching a single attribute change instead of a stylesheet swap.

What Went Wrong During the Build

Object.assign mutated the primitive. The compound component pattern uses Object.assign(Root, { Trigger, Content, ... }) to create the dot-notation API. When the styled wrapper did Object.assign(DialogPrimitive, { Content: StyledContent }), it replaced Dialog.Content globally — including for consumers importing from flintwork/primitives. A consumer using the headless layer would silently get styled sub-components.

The fix: create a new root function instead of mutating the imported primitive.

// Wrong — mutates the primitive
export const Dialog = Object.assign(DialogPrimitive, { ... });

// Right — new object, primitive untouched
function StyledDialogRoot(props) {
  return <DialogPrimitive {...props} />;
}
export const Dialog = Object.assign(StyledDialogRoot, { ... });
Enter fullscreen mode Exit fullscreen mode

This is the kind of bug that doesn't show up in tests because tests import from one entry point. It shows up in production when two teams import from different entry points in the same app.

Polymorphic typing collapsed. The styled Button wraps the primitive Button, which supports <Button as="a" href="...">. But React.ComponentPropsWithoutRef<typeof ButtonPrimitive> doesn't thread the generic through — TypeScript collapses it to the default 'button' case and href becomes a type error.

The fix: re-declare the full polymorphic type on the styled wrapper. Same pattern as the primitive — generic T extends ElementType, Omit<ComponentPropsWithoutRef<T>, keyof OwnProps>. The styled layer can't inherit polymorphic types automatically. It has to re-establish them.

CSS files don't copy themselves. tsup compiles TypeScript. It does not touch CSS files. After tsup runs, dist/styled/button.css doesn't exist even though package.json exports point to it. Added an onSuccess hook in the tsup config that copies CSS files to dist/ after every build.

What I'd Build Next

Scroll lock on Dialog. When the dialog opens, the page behind it should stop scrolling. Right now it doesn't. The fix is straightforward — set overflow: hidden on document.body when the dialog mounts, restore on unmount. Deferred because it's a CSS concern, not a behavior concern, and the focus trap already prevents keyboard scrolling. Mouse wheel scrolling on the overlay is the gap.

CSS animations. The overlay and content both appear and disappear instantly. A fade-in on the overlay and a scale-in on the content would make the transitions feel finished. The styled layer already has data-state="open" and data-state="closed" attributes from the primitive — CSS transitions can target those without any JavaScript changes. The animation is purely a presentation concern, which is exactly what the styled layer is for.

More components. Three components prove the architecture. Ten components prove it scales. Tooltip, Select, Popover, Menu, Accordion — each one follows the same pattern: headless primitive with hooks, thin styled wrapper with data attributes, CSS file with intermediate variables, component tokens in JSON. The first component took days. Each subsequent one takes hours. That ratio is the return on the architectural investment.


This is Part 3 of the Building flintwork series. Part 1 covers the token pipeline. Part 2 covers the headless primitives and accessibility. The full source is on GitHub.

Top comments (0)