Tailwind CSS 51 🎨 Building Headless Component Libraries with Tailwind and React/Vue/Svelte
A headless component library ships behavior without appearance. It handles keyboard navigation, focus management, ARIA attributes, and state transitions, but it does not dictate what the component looks like. The consumer supplies the styling. This separation is powerful because it means one library can serve React, Vue, and Svelte applications without shipping framework-specific markup, and it means designers are never fighting a library’s opinions about padding or color. Tailwind CSS is a natural fit for the styling layer in this architecture, because utility classes are injected by the consumer at the point of use, not baked into the component. The result is a library that provides accessibility and behavior as a headless primitive, and lets the consuming application assemble the visual design from Tailwind utilities.
Key point: Headless components are not unstyled components. They are components with no opinion about styling. The behavior is fixed and correct; the appearance is entirely the consumer’s responsibility, and Tailwind is the mechanism for expressing that appearance inline.
Why headless component libraries exist
The styling-coupling problem. Traditional component libraries ship with a visual design. The button has a specific padding, border radius, and color. The dropdown has a specific animation and shadow. This works well when the library’s design matches the application’s, and badly when it does not. The consumer either accepts the library’s look, overrides it with fragile CSS, or forks the component. All three outcomes are undesirable.
The accessibility problem. Building an accessible dropdown, combobox, dialog, or menu is not trivial. Correct keyboard navigation, focus trapping, aria-expanded, aria-activedescendant, and screen reader announcements require substantial expertise. Most application teams do not want to reimplement this behavior per component, but they also do not want the library’s design. Headless components solve both: the behavior is packaged and reusable, and the appearance remains with the application.
The framework-reuse problem. React, Vue, and Svelte have different rendering models, different reactivity systems, and different component APIs. A component library that ships styled components is committed to one framework. A headless behavior library can be ported across frameworks because the behavior is abstract: “on ArrowDown, move focus to the next item” is the same instruction in every framework. The styling layer, written in Tailwind, is framework-agnostic because the classes are strings, not components.
The design-system problem. Applications with a design system need components that express that system’s tokens: its spacing scale, its color palette, its motion curves. A styled library fights this. A headless library defers to it, because the consumer writes the classes. Tailwind’s utility vocabulary maps directly onto design tokens, so the headless-plus-Tailwind combination lets the design system live in the class strings rather than in a library’s internals.
The state-exposure problem. Headless components must expose their internal state so the consumer can style based on it. A dropdown needs to tell the consumer “I am open” or “this item is highlighted” so the consumer can apply different classes. This is typically achieved through data attributes, such as data-state="open", which Tailwind can target with arbitrary variants. The state lives in the DOM as an attribute, and the styling lives in the consumer’s class strings.
a. The headless architecture
A headless component has three layers. The behavior layer manages state, keyboard interaction, and focus. The accessibility layer applies ARIA attributes and manages relationships between elements. The presentation layer is empty, waiting for the consumer to fill it. Tailwind fills that layer.
The behavior layer
The behavior layer is the component’s logic. It tracks open and closed states, handles keyboard events, and manages focus. It does not know or care what the component looks like.
import { useState, useRef, useEffect } from 'react';
function useDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const triggerRef = useRef(null);
const menuRef = useRef(null);
function toggle() {
setIsOpen(prev => !prev);
}
function close() {
setIsOpen(false);
setActiveIndex(-1);
}
return {
isOpen,
activeIndex,
triggerRef,
menuRef,
toggle,
close,
setActiveIndex,
};
}
The hook returns state and handlers. The consumer decides how to render them.
The accessibility layer
The accessibility layer applies the correct ARIA attributes and connects the trigger to the menu. This is where headless libraries earn their value: getting these attributes correct is difficult, and getting them wrong breaks the component for screen reader users.
function Dropdown({ items, onSelect }) {
const {
isOpen, activeIndex, triggerRef, menuRef,
toggle, close, setActiveIndex,
} = useDropdown();
return (
<>
<button
ref={triggerRef}
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={toggle}
>
Select an item
</button>
{isOpen && (
<ul
ref={menuRef}
role="listbox"
aria-activedescendant={
activeIndex >= 0 ? `item-${activeIndex}` : undefined
}
>
{items.map((item, i) => (
<li
key={item.id}
id={`item-${i}`}
role="option"
aria-selected={i === activeIndex}
onClick={() => { onSelect(item); close(); }}
>
{item.label}
</li>
))}
</ul>
)}
</>
);
}
The component provides behavior and accessibility. It provides no classes. The rendering is plain elements with ARIA attributes.
The presentation layer
The presentation layer is the consumer’s responsibility. The consumer passes Tailwind classes to the component, and the component forwards them to the appropriate elements. The common pattern is to accept a className prop, or to expose a classNames object for multiple elements.
<Dropdown
items={items}
onSelect={handleSelect}
className="w-48 rounded-md border border-gray-300 bg-white shadow-lg"
itemClassName="px-4 py-2 hover:bg-gray-100"
/>
The component merges these classes with its own minimal defaults, or applies them directly. The result is a component that behaves correctly and looks exactly how the consumer wants.
The data-attribute pattern
To let the consumer style based on state, headless components expose state as data attributes. Tailwind’s arbitrary variants can target these attributes, so the consumer can write data-[state=open]:bg-blue-500 and get state-dependent styling without JavaScript.
<ul
ref={menuRef}
role="listbox"
data-state={isOpen ? 'open' : 'closed'}
className="data-[state=open]:block data-[state=closed]:hidden"
>
The data-state attribute is set by the component. The consumer writes the class. Tailwind generates the CSS rule that matches the attribute selector.
b. Styling with Tailwind
Tailwind’s utility classes are the natural styling layer for headless components. The classes are strings, so they can be passed through props, merged with defaults, and applied conditionally. This section covers the patterns that make this integration clean.
The className forwarding pattern
The simplest integration is forwarding a className prop to the root element. The component accepts the prop, merges it with any base classes, and applies the result.
function Button({ className = '', children, ...props }) {
const base = 'inline-flex items-center justify-center rounded font-medium';
return (
<button className={`${base} ${className}`} {...props}>
{children}
</button>
);
}
// Usage:
<Button className="bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Save
</Button>
The consumer controls padding, color, and hover behavior. The component controls nothing visual.
Merging classes with tailwind-merge
String concatenation has a problem: conflicting utilities do not resolve correctly. If the base classes include px-4 and the consumer passes px-6, both classes end up in the class string, and CSS order determines the winner, which is unpredictable. The tailwind-merge library resolves this by keeping only the last conflicting utility.
import { twMerge } from 'tailwind-merge';
function Button({ className = '', children, ...props }) {
const base = 'inline-flex items-center rounded font-medium px-4 py-2';
return (
<button className={twMerge(base, className)} {...props}>
{children}
</button>
);
}
// Consumer passes px-6; twMerge keeps px-6 and drops px-4.
<Button className="px-6">Save</Button>
The twMerge function knows the Tailwind utility taxonomy and resolves conflicts within the same property group.
The variant pattern with cva
For components with predefined variants, the class-variance-authority (cva) library provides a declarative API for defining variants and their classes. Each variant maps to a set of utilities, and the component applies the correct set based on the props.
import { cva } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded font-medium transition-colors',
{
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
},
}
);
function Button({ intent, size, className, ...props }) {
return (
<button
className={twMerge(buttonVariants({ intent, size }), className)}
{...props}
/>
);
}
The consumer selects a variant by prop, and can still override individual utilities with className. The component ships with reasonable defaults but imposes no fixed appearance.
The data-attribute variants
Tailwind 3.2 introduced arbitrary variants that target data attributes. This lets the consumer style a headless component based on the state attributes the component sets.
<ul
role="listbox"
data-state={isOpen ? 'open' : 'closed'}
className="
data-[state=open]:animate-in
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
"
>
The component sets data-state, and the consumer writes the Tailwind classes. The styling follows the state without the consumer needing to know the component’s internals.
The peer and group patterns
Tailwind’s peer and group variants let a consumer style an element based on a sibling’s or ancestor’s state, using only CSS. Headless components that expose state as data attributes or classes can be styled with these variants.
<div className="group">
<button className="group-data-[state=open]:bg-blue-500">
Toggle
</button>
</div>
The group-data-[state=open] variant applies the class when the ancestor with the group class has data-state="open". The component sets the attribute; the consumer writes the class.
The asChild pattern
Some libraries, such as Radix UI, expose an asChild prop that lets the consumer replace the component’s default element with their own. This is useful for rendering a dropdown trigger as a link or a custom button. The component forwards its behavior and accessibility props to the child element.
<Dropdown.Trigger asChild>
<a href="#" className="text-blue-600 underline">
Open menu
</a>
</Dropdown.Trigger>
The behavior and ARIA attributes are merged into the child’s props. The consumer controls the element and its classes. This is how headless libraries avoid dictating even the tag name.
c. Implementing across frameworks
The headless-plus-Tailwind pattern works in React, Vue, and Svelte, with small differences in how state and props are exposed. The core idea is the same: the component provides behavior and accessibility, and the consumer provides the Tailwind classes.
React
React uses hooks for behavior and props for styling. The useDropdown hook returns state and handlers, and the component forwards className to the appropriate elements.
function Dropdown({ items, onSelect, className, itemClassName }) {
const { isOpen, activeIndex, toggle, close, setActiveIndex } = useDropdown();
return (
<>
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={toggle}
className={className}
>
Select
</button>
{isOpen && (
<ul role="listbox" className="mt-1">
{items.map((item, i) => (
<li
key={item.id}
role="option"
aria-selected={i === activeIndex}
onClick={() => { onSelect(item); close(); }}
className={itemClassName}
>
{item.label}
</li>
))}
</ul>
)}
</>
);
}
The consumer passes className and itemClassName with Tailwind utilities. The component applies them to the trigger and the items.
Vue
Vue uses the composition API for behavior and class attributes for styling. The component accepts class props and binds them to the template.
<script setup>
import { ref } from 'vue';
const props = defineProps({
items: Array,
class: String,
itemClass: String,
});
const isOpen = ref(false);
function toggle() {
isOpen.value = !isOpen.value;
}
</script>
<template>
<button
:aria-expanded="isOpen"
aria-haspopup="listbox"
:class="props.class"
@click="toggle"
>
Select
</button>
<ul v-if="isOpen" role="listbox" class="mt-1">
<li
v-for="item in items"
:key="item.id"
role="option"
:class="props.itemClass"
>
{{ item.label }}
</li>
</ul>
</template>
Vue’s class attribute is merged with the component’s own classes by default, so the consumer’s utilities are applied alongside any base classes.
Svelte
Svelte uses reactive statements for behavior and class directives for styling. The component accepts class props and applies them with the class attribute.
<script>
export let items = [];
export let class: className = '';
export let itemClass = '';
let isOpen = false;
function toggle() {
isOpen = !isOpen;
}
</script>
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
class={className}
on:click={toggle}
>
Select
</button>
{#if isOpen}
<ul role="listbox" class="mt-1">
{#each items as item (item.id)}
<li role="option" class={itemClass}>
{item.label}
</li>
{/each}
</ul>
{/if}
Svelte’s class attribute is applied directly. The consumer passes Tailwind utilities as strings, and the component renders them.
Shared packages
A common architecture is to publish a core behavior package that is framework-agnostic, and thin adapters for each framework. The core package contains the state machine, keyboard handlers, and ARIA logic. The framework packages wire the core into the framework’s component model. The Tailwind classes flow from the consumer through the adapter to the DOM.
headless-core (framework-agnostic behavior)
├── headless-react (React adapter)
├── headless-vue (Vue adapter)
└── headless-svelte (Svelte adapter)
The consumer imports the adapter for their framework and passes Tailwind classes. The behavior is identical across frameworks because it lives in the core package.
The slot pattern for composition
Headless libraries often expose sub-components, such as Dropdown.Trigger, Dropdown.Menu, and Dropdown.Item, so the consumer can compose them in any order. Each sub-component receives its own className prop, and the consumer styles each part independently.
<Dropdown>
<Dropdown.Trigger className="rounded border px-4 py-2">
Open
</Dropdown.Trigger>
<Dropdown.Menu className="mt-1 w-48 rounded-md bg-white shadow-lg">
<Dropdown.Item className="px-4 py-2 hover:bg-gray-100">
Profile
</Dropdown.Item>
<Dropdown.Item className="px-4 py-2 hover:bg-gray-100">
Settings
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
Each part of the component has its own styling hook. The consumer writes the classes for the trigger, the menu, and the items separately.
Complete Example Session
// ============================================
// PART 1: THE HEADLESS DROPDOWN CORE
// ============================================
// A framework-agnostic behavior hook.
// No classes, no styling, only state and handlers.
import { useState, useRef, useEffect, useCallback } from 'react';
function useDropdown({ items, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const triggerRef = useRef(null);
const menuRef = useRef(null);
const open = useCallback(() => {
setIsOpen(true);
setActiveIndex(0);
}, []);
const close = useCallback(() => {
setIsOpen(false);
setActiveIndex(-1);
}, []);
const toggle = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const selectItem = useCallback((index) => {
onSelect(items[index]);
close();
triggerRef.current?.focus();
}, [items, onSelect, close]);
// Keyboard navigation.
const handleKeyDown = useCallback((e) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => (prev + 1) % items.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => (prev - 1 + items.length) % items.length);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (activeIndex >= 0) selectItem(activeIndex);
break;
case 'Escape':
e.preventDefault();
close();
triggerRef.current?.focus();
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
}
}, [isOpen, activeIndex, items.length, open, close, selectItem]);
// Close on outside click.
useEffect(() => {
if (!isOpen) return;
function handleClick(e) {
if (
!triggerRef.current?.contains(e.target) &&
!menuRef.current?.contains(e.target)
) {
close();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen, close]);
return {
isOpen,
activeIndex,
triggerRef,
menuRef,
toggle,
open,
close,
setActiveIndex,
selectItem,
handleKeyDown,
};
}
// ============================================
// PART 2: THE HEADLESS COMPONENT
// ============================================
// The component provides behavior and accessibility.
// The consumer provides classes via props.
function Dropdown({
items,
onSelect,
className = '',
itemClassName = '',
activeItemClassName = '',
placeholder = 'Select an item',
}) {
const {
isOpen,
activeIndex,
triggerRef,
menuRef,
toggle,
setActiveIndex,
selectItem,
handleKeyDown,
} = useDropdown({ items, onSelect });
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={toggle}
onKeyDown={handleKeyDown}
data-state={isOpen ? 'open' : 'closed'}
className={className}
>
{placeholder}
</button>
{isOpen && (
<ul
ref={menuRef}
role="listbox"
tabIndex={-1}
onKeyDown={handleKeyDown}
data-state="open"
className="absolute z-10 mt-1"
>
{items.map((item, index) => (
<li
key={item.id}
id={`dropdown-item-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => selectItem(index)}
onMouseEnter={() => setActiveIndex(index)}
data-active={index === activeIndex}
className={`${itemClassName} ${
index === activeIndex ? activeItemClassName : ''
}`}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
// ============================================
// PART 3: THE CONSUMER STYLES WITH TAILWIND
// ============================================
// The consumer passes utility classes for every visual decision.
function App() {
const items = [
{ id: 'profile', label: 'Profile' },
{ id: 'settings', label: 'Settings' },
{ id: 'logout', label: 'Log out' },
];
function handleSelect(item) {
console.log('Selected:', item.label);
}
return (
<Dropdown
items={items}
onSelect={handleSelect}
placeholder="Account menu"
className="
inline-flex items-center justify-between gap-2
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
shadow-sm hover:bg-gray-50
focus:outline-none focus:ring-2 focus:ring-blue-500
data-[state=open]:ring-2 data-[state=open]:ring-blue-500
"
itemClassName="
cursor-pointer px-4 py-2 text-sm text-gray-700
first:rounded-t-md last:rounded-b-md
"
activeItemClassName="bg-blue-50 text-blue-700"
/>
);
}
// ============================================
// PART 4: VARIANT-BASED STYLING WITH cva
// ============================================
import { cva } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
const triggerVariants = cva(
'inline-flex items-center justify-between gap-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2',
{
variants: {
intent: {
neutral: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-blue-500',
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-300',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-300',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
intent: 'neutral',
size: 'md',
},
}
);
function DropdownTrigger({ intent, size, className, ...props }) {
return (
<button
type="button"
className={twMerge(triggerVariants({ intent, size }), className)}
{...props}
/>
);
}
// ============================================
// PART 5: VUE ADAPTER
// ============================================
// The same behavior, adapted to Vue's composition API.
// useDropdown.js (Vue)
import { ref, computed } from 'vue';
export function useDropdown(items, onSelect) {
const isOpen = ref(false);
const activeIndex = ref(-1);
const triggerRef = ref(null);
const menuRef = ref(null);
function toggle() {
isOpen.value = !isOpen.value;
}
function close() {
isOpen.value = false;
activeIndex.value = -1;
}
function selectItem(index) {
onSelect(items[index]);
close();
triggerRef.value?.focus();
}
function handleKeyDown(e) {
if (!isOpen.value) {
if (['ArrowDown', 'Enter', ' '].includes(e.key)) {
e.preventDefault();
isOpen.value = true;
activeIndex.value = 0;
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
activeIndex.value = (activeIndex.value + 1) % items.length;
break;
case 'ArrowUp':
e.preventDefault();
activeIndex.value = (activeIndex.value - 1 + items.length) % items.length;
break;
case 'Enter':
case ' ':
e.preventDefault();
if (activeIndex.value >= 0) selectItem(activeIndex.value);
break;
case 'Escape':
e.preventDefault();
close();
triggerRef.value?.focus();
break;
}
}
return {
isOpen, activeIndex, triggerRef, menuRef,
toggle, close, setActiveIndex: (i) => { activeIndex.value = i; },
selectItem, handleKeyDown,
};
}
// ============================================
// PART 6: VUE COMPONENT
// ============================================
// Dropdown.vue
<script setup>
import { useDropdown } from './useDropdown';
const props = defineProps({
items: Array,
onSelect: Function,
class: String,
itemClass: String,
activeItemClass: String,
placeholder: { type: String, default: 'Select' },
});
const {
isOpen, activeIndex, triggerRef, menuRef,
toggle, setActiveIndex, selectItem, handleKeyDown,
} = useDropdown(props.items, props.onSelect);
</script>
<template>
<div class="relative">
<button
ref="triggerRef"
type="button"
aria-haspopup="listbox"
:aria-expanded="isOpen"
:data-state="isOpen ? 'open' : 'closed'"
:class="props.class"
@click="toggle"
@keydown="handleKeyDown"
>
{{ props.placeholder }}
</button>
<ul
v-if="isOpen"
ref="menuRef"
role="listbox"
class="absolute z-10 mt-1"
>
<li
v-for="(item, index) in props.items"
:key="item.id"
role="option"
:aria-selected="index === activeIndex"
:data-active="index === activeIndex"
:class="[props.itemClass, index === activeIndex ? props.activeItemClass : '']"
@click="selectItem(index)"
@mouseenter="setActiveIndex(index)"
>
{{ item.label }}
</li>
</ul>
</div>
</template>
// ============================================
// PART 7: SVELTE ADAPTER
// ============================================
// Dropdown.svelte
<script>
export let items = [];
export let onSelect = () => {};
export let class: className = '';
export let itemClass = '';
export let activeItemClass = '';
export let placeholder = 'Select';
let isOpen = false;
let activeIndex = -1;
let triggerRef;
let menuRef;
function toggle() {
isOpen = !isOpen;
}
function close() {
isOpen = false;
activeIndex = -1;
}
function selectItem(index) {
onSelect(items[index]);
close();
triggerRef?.focus();
}
function handleKeyDown(e) {
if (!isOpen) {
if (['ArrowDown', 'Enter', ' '].includes(e.key)) {
e.preventDefault();
isOpen = true;
activeIndex = 0;
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
activeIndex = (activeIndex + 1) % items.length;
break;
case 'ArrowUp':
e.preventDefault();
activeIndex = (activeIndex - 1 + items.length) % items.length;
break;
case 'Enter':
case ' ':
e.preventDefault();
if (activeIndex >= 0) selectItem(activeIndex);
break;
case 'Escape':
e.preventDefault();
close();
triggerRef?.focus();
break;
}
}
</script>
<div class="relative">
<button
bind:this={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
data-state={isOpen ? 'open' : 'closed'}
class={className}
on:click={toggle}
on:keydown={handleKeyDown}
>
{placeholder}
</button>
{#if isOpen}
<ul bind:this={menuRef} role="listbox" class="absolute z-10 mt-1">
{#each items as item, index (item.id)}
<li
role="option"
aria-selected={index === activeIndex}
data-active={index === activeIndex}
class="{itemClass} {index === activeIndex ? activeItemClass : ''}"
on:click={() => selectItem(index)}
on:mouseenter={() => (activeIndex = index)}
>
{item.label}
</li>
{/each}
</ul>
{/if}
</div>
// ============================================
// PART 8: TAILWIND DATA-ATTRIBUTE VARIANTS
// ============================================
// The component sets data-state and data-active.
// The consumer styles based on those attributes.
<button
data-state={isOpen ? 'open' : 'closed'}
className="
rounded-md border px-4 py-2
data-[state=open]:border-blue-500
data-[state=open]:ring-2
data-[state=open]:ring-blue-200
data-[state=closed]:border-gray-300
"
>
Toggle
</button>
<li
data-active={index === activeIndex}
className="
px-4 py-2
data-[active=true]:bg-blue-50
data-[active=true]:text-blue-700
"
>
{item.label}
</li>
// ============================================
// PART 9: THE asChild PATTERN
// ============================================
// The consumer replaces the default trigger element.
import { cloneElement, isValidElement } from 'react';
function Slot({ children, ...props }) {
if (isValidElement(children)) {
return cloneElement(children, {
...props,
...children.props,
className: twMerge(props.className, children.props.className),
});
}
return null;
}
function DropdownTrigger({ asChild, children, className, ...props }) {
if (asChild) {
return <Slot className={className} {...props}>{children}</Slot>;
}
return (
<button type="button" className={className} {...props}>
{children}
</button>
);
}
// Usage:
<DropdownTrigger asChild>
<a href="#" className="text-blue-600 underline">
Open menu
</a>
</DropdownTrigger>
// The anchor receives the behavior props and the classes.
// The consumer controls the element and its styling.
// ============================================
// PART 10: PUBLISHING THE LIBRARY
// ============================================
// Package structure:
//
// @acme/headless-core
// - framework-agnostic state machines and ARIA logic
//
// @acme/headless-react
// - React components that wrap the core
//
// @acme/headless-vue
// - Vue components that wrap the core
//
// @acme/headless-svelte
// - Svelte components that wrap the core
//
// Each adapter:
// - Accepts className / class props
// - Forwards them to the DOM
// - Sets data-state attributes for Tailwind variants
// - Provides no default visual styling
//
// The consumer:
// - Imports the adapter for their framework
// - Passes Tailwind classes for every visual decision
// - Styles state via data-[state=...] variants
//
// The result:
// - Behavior is consistent across frameworks
// - Styling is entirely in the consumer's control
// - Tailwind utilities express the design system
// - No CSS is shipped by the library itself
The ten parts progressed from the headless behavior hook through the component, Tailwind consumer usage, variant styling, Vue and Svelte adapters, data-attribute variants, the asChild pattern, and publishing. The same dropdown scenario ran through every framework, showing how the behavior stays consistent while the styling remains entirely with the consumer.
Quick Reference
Headless Component Layers
| Layer | Responsibility | Owner |
|---|---|---|
| Behavior | State, keyboard, focus | Library |
| Accessibility | ARIA, relationships | Library |
| Presentation | Classes, colors, spacing | Consumer |
| State exposure | Data attributes | Library |
| State styling | Tailwind variants | Consumer |
Styling Integration Patterns
| Pattern | Use Case | Example |
|---|---|---|
className forwarding | Single element | <Button className="..." /> |
classNames object | Multiple elements | classNames={{ trigger, item }} |
tailwind-merge | Conflict resolution | twMerge(base, className) |
cva | Variant presets | buttonVariants({ intent, size }) |
data-[state=...] | State styling | data-[state=open]:bg-blue-500 |
asChild | Element replacement | <Trigger asChild><a /></Trigger> |
Framework Class Handling
| Framework | Class Prop | Merge Behavior |
|---|---|---|
| React | className | Manual with twMerge |
| Vue | class | Automatic merge |
| Svelte | class | Direct assignment |
| Solid | class | Direct assignment |
Data Attribute Conventions
| Attribute | Values | Purpose |
|---|---|---|
data-state | open, closed | Open/close state |
data-active | true, false | Highlighted item |
data-disabled | true, false | Disabled state |
data-selected | true, false | Selected item |
data-side | top, bottom, left, right | Popover placement |
Best Practices
✅ Do This:
// Forward className to the root element.
<button className={twMerge(base, className)} /> // ✅
// Use tailwind-merge to resolve conflicts.
twMerge('px-4 py-2', 'px-6') // ✅
// Expose state as data attributes.
data-state={isOpen ? 'open' : 'closed'} // ✅
// Style state with arbitrary variants.
className="data-[state=open]:bg-blue-500" // ✅
// Accept classNames objects for multi-element components.
<Dropdown classNames={{ trigger: '...', item: '...' }} /> // ✅
// Use cva for variant presets.
const variants = cva('base', { variants: { intent: {...} } }); // ✅
// Support asChild for element replacement.
<Trigger asChild><a href="#" /></Trigger> // ✅
// Keep behavior framework-agnostic in a core package.
// Adapters wire the core into each framework. // ✅
// Document the data attributes the component sets.
// Consumers rely on them for styling. // ✅
❌ Don’t Do This:
// Don't ship default visual styling.
<button className="px-4 py-2 bg-blue-600 text-white" /> // ❌ Opinionated
// Don't concatenate classes without merging.
className={`${base} ${className}`} // ❌ Conflict unpredictable
// Don't use inline styles for theming.
style={{ padding: '8px 16px' }} // ❌ Not Tailwind-compatible
// Don't hardcode a single element type.
<button>...</button> // ❌ Use asChild for flexibility
// Don't skip ARIA attributes.
<button onClick={toggle}> // ❌ Missing aria-expanded
// Don't expose internal state via CSS classes only.
className={isOpen ? 'open' : ''} // ❌ Use data attributes
// Don't duplicate behavior per framework.
// Share the core; adapt only the rendering. // ❌
// Don't force the consumer to override !important.
// The library should have no styles to override. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Class conflicts unresolved | String concatenation | Use tailwind-merge |
| State styling broken | No data attributes | Expose state via data-* |
| Consumer cannot override | Library ships base styles | Ship zero visual styles |
| Framework lock-in | Behavior in framework code | Extract core package |
| asChild loses props | Props not merged | Merge behavior, accessibility, classes |
| Tailwind classes purged | Dynamic class strings | Use static classes or safelist |
| Vue class merge unexpected | Vue merges automatically | Understand Vue’s class binding |
| Svelte class directive conflicts | Multiple class sources | Use template literal composition |
Real-World Examples
1. Headless Button with className
function Button({ className, ...props }) {
return <button className={twMerge('rounded font-medium', className)} {...props} />;
}
2. cva Variants
const variants = cva('rounded', {
variants: {
intent: { primary: 'bg-blue-600', danger: 'bg-red-600' },
size: { sm: 'px-2 py-1', lg: 'px-6 py-3' },
},
});
3. Data-State Styling
<ul data-state={isOpen ? 'open' : 'closed'} className="data-[state=open]:block hidden" />
4. Data-Active Item Styling
<li data-active={isActive} className="data-[active=true]:bg-blue-50" />
5. asChild Pattern
<Trigger asChild>
<a href="#" className="text-blue-600">Open</a>
</Trigger>
6. Vue Class Forwarding
<button :class="props.class" @click="toggle">...</button>
7. Svelte Class Composition
<button class="{className} {isOpen ? activeClass : ''}">...</button>
8. Shared Core Package
@acme/core ← behavior
@acme/react ← React adapter
@acme/vue ← Vue adapter
9. classNames Object
<Dropdown classNames={{ trigger: '...', menu: '...', item: '...' }} />
10. Tailwind Arbitrary Variants for State
className="data-[state=open]:ring-2 data-[state=closed]:ring-0"
Visual
The Headless Architecture
┌────────────────────────────────────────────────────────────┐
│ HEADLESS COMPONENT ARCHITECTURE │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ BEHAVIOR LAYER (library) │ │
│ │ State, keyboard, focus, outside-click │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ACCESSIBILITY LAYER (library) │ │
│ │ ARIA roles, aria-expanded, aria-selected │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PRESENTATION LAYER (consumer) │ │
│ │ Tailwind classes via className props │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RENDERED DOM │ │
│ │ <button aria-expanded="true" │ │
│ │ data-state="open" │ │
│ │ class="rounded border px-4 py-2"> │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Library owns: behavior + accessibility. │
│ Consumer owns: every visual decision. │
│ │
└────────────────────────────────────────────────────────────┘
Data-Attribute State Styling
┌────────────────────────────────────────────────────────────┐
│ STATE EXPOSED AS DATA ATTRIBUTES │
│ │
│ Component sets the attribute: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ <button data-state={isOpen ? 'open' : 'closed'}> │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ Consumer writes the class: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ className=" │ │
│ │ data-[state=open]:bg-blue-500 │ │
│ │ data-[state=closed]:bg-gray-100" │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ Tailwind generates: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ [data-state="open"] { background: blue-500 } │ │
│ │ [data-state="closed"] { background: gray-100 } │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The state lives in the DOM. │
│ The styling lives in the consumer's class strings. │
│ No JavaScript is needed to toggle classes. │
│ │
└────────────────────────────────────────────────────────────┘
Cross-Framework Adapters
┌────────────────────────────────────────────────────────────┐
│ ONE CORE, THREE ADAPTERS │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ @acme/headless-core │ │
│ │ useDropdown() useDialog() useTooltip() │ │
│ │ Framework-agnostic state machines │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ v v v │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ React │ │ Vue │ │ Svelte │ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │ │ │
│ v v v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Consumer passes Tailwind classes │ │
│ │ className / class / class │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Behavior is identical across frameworks. │
│ Styling is identical because Tailwind classes are strings.│
│ The consumer writes the same utility vocabulary │
│ regardless of the framework underneath. │
│ │
└────────────────────────────────────────────────────────────┘
The asChild Pattern
┌────────────────────────────────────────────────────────────┐
│ asChild: CONSUMER CONTROLS THE ELEMENT │
│ │
│ WITHOUT asChild │
│ ┌──────────────────────────────────────────────────┐ │
│ │ <DropdownTrigger className="..."> │ │
│ │ Open │ │
│ │ </DropdownTrigger> │ │
│ │ Renders: <button className="...">Open</button> │ │
│ │ The library chooses the element. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ WITH asChild │
│ ┌──────────────────────────────────────────────────┐ │
│ │ <DropdownTrigger asChild> │ │
│ │ <a href="#" className="text-blue-600">Open</a> │ │
│ │ </DropdownTrigger> │ │
│ │ Renders: <a href="#" className="text-blue-600" │ │
│ │ aria-expanded="..." │ │
│ │ data-state="...">Open</a> │ │
│ │ The consumer chooses the element. │ │
│ │ The library merges behavior props and classes. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ asChild lets the consumer render the trigger as a link, │
│ a custom button, or any element that accepts the │
│ behavior props. The library never dictates the tag. │
│ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Headless component | Behavior and accessibility without visual styling |
| Behavior layer | State, keyboard, focus, outside-click |
| Accessibility layer | ARIA roles and relationships |
| Presentation layer | Consumer-provided Tailwind classes |
| Data attributes | State exposed as data-state, data-active |
| Arbitrary variants | Tailwind classes targeting data attributes |
tailwind-merge | Resolves conflicting utility classes |
cva | Declarative variant definitions |
asChild | Consumer replaces the default element |
| Core package | Framework-agnostic behavior shared across adapters |
| Adapters | Thin wrappers wiring core into React, Vue, Svelte |
Key takeaways:
- Headless means no visual opinion. The library ships behavior and accessibility. The consumer ships every visual decision as Tailwind classes. The library’s stylesheet is empty.
- Data attributes expose state to Tailwind. The component sets
data-state,data-active, and similar attributes. The consumer writesdata-[state=open]:bg-blue-500and Tailwind generates the matching CSS. State changes do not require JavaScript class toggling. tailwind-mergeresolves conflicts. Concatenating class strings produces unpredictable results when utilities conflict.twMergekeeps the last utility in each property group, so consumer overrides work as expected.cvastructures variants. For components with predefined intents and sizes,cvamaps each variant to a set of utilities. The component composes the base, the variant, and the consumer’s override.asChildgives the consumer full control. The consumer can replace the default element with any element they want, and the library merges its behavior props and accessibility attributes into the child. The library never dictates the tag.- One core, multiple adapters. The behavior lives in a framework-agnostic package. Each framework gets a thin adapter that wires the core into the framework’s component model. The Tailwind classes flow from the consumer through the adapter to the DOM.
- The design system lives in the classes. Because the consumer writes all classes, the design system is expressed in the class strings. The library never imposes a token, a spacing scale, or a color. The consumer’s Tailwind configuration is the single source of design truth.
- Cross-framework consistency comes from the core. The same behavior runs in React, Vue, and Svelte because the behavior is not framework-specific. The rendering differs, but the interaction, the keyboard handling, and the accessibility are identical.
Remember: A headless component library is a contract: the library provides correct behavior and accessibility, and the consumer provides appearance. Tailwind CSS is the natural styling layer for this contract because its classes are strings, not components, and can be passed through props, merged, and conditionally applied. The component sets data attributes to expose its state, and the consumer writes arbitrary variants to style based on that state. The library ships zero visual CSS. Every color, every padding, every border radius is the consumer’s decision, expressed in the same utility vocabulary the consumer uses everywhere else. The result is a component that behaves correctly, looks exactly how the application needs it to look, and works identically across React, Vue, and Svelte.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!