Vue.js 80 🟢 Headless Component Patterns and Integration with Tailwind CSS
Headless components invert the traditional relationship between a UI library and its styling. Instead of shipping pre-styled components that you override with CSS, a headless library provides the behavior—state management, accessibility attributes, keyboard navigation, focus handling—and leaves every visual decision to you. Tailwind CSS is a natural fit for this model because it gives you utility classes to style the DOM directly, without fighting a component library’s opinionated CSS.
This chapter examines the headless pattern in Vue 3, how it separates logic from presentation, and how to integrate it with Tailwind CSS in a way that keeps both layers clean. It covers the composable-versus-component decision, the slot prop and data attribute conventions that headless libraries use, and the specific Tailwind v4 configuration required when consuming headless components from packages.
Key point: Headless components do not mean “unstyled components.” They mean components where the behavior is guaranteed and the styling is entirely your responsibility. Tailwind makes that responsibility practical by providing a styling vocabulary that lives in the template, not in a separate stylesheet.
Why headless patterns exist
The override problem. Styled component libraries ship with default appearance. Customizing them means writing CSS that overrides the library’s CSS, which creates specificity conflicts, version fragility, and the constant question of whether a style change is yours or the library’s. Headless libraries eliminate this by having no default appearance. There is nothing to override because there is no base style .
The behavior duplication problem. Building accessible components from scratch is difficult. A combobox needs roving tabindex, typeahead, aria-activedescendant, and focus management. A dialog needs focus trapping, scroll locking, and escape handling. Headless libraries encode these patterns once and expose them through composables or components, so you write the styling and they handle the correctness .
The Tailwind alignment problem. Tailwind’s utility classes live in the template, applied directly to elements. A styled component library that controls its own markup makes this difficult: you cannot easily add class="px-4 py-2 bg-blue-600" to an internal <button> that the library owns. Headless components expose the markup as slot content or renderless primitives, so the element receiving Tailwind classes is the same element receiving the behavior attributes .
The composable-versus-component question. Vue 3’s Composition API provides an alternative to renderless components for logic reuse. A composable is a function that returns reactive state and methods; a renderless component is a component that exposes logic through scoped slots but renders no markup. Composables have less overhead—no component instance, no lifecycle hooks—while renderless components are better when slot-based composition is genuinely needed . Headless libraries typically offer both: a composable for pure logic, and a thin component wrapper for template convenience.
a. The Headless Component Model
A headless component in Vue exposes its behavior through two primary mechanisms: slot props and data attributes. Slot props pass down the attributes and event handlers that the rendered element must spread. Data attributes expose the component’s internal state so that Tailwind can style it conditionally.
The Vuetify0 project’s headless components illustrate the pattern clearly. Every component exposes an attrs object in its default slot. Spreading that object onto your element applies the click handler, ARIA attributes, and data-* state attributes .
<!-- A headless toggle component in use -->
<script setup lang="ts">
import { useToggle } from 'some-headless-lib'
// The composable returns state and a getter for element attributes
const { isOn, getToggleProps } = useToggle()
</script>
<template>
<!-- Spread the props onto the element you control -->
<!-- You apply Tailwind classes; the library applies behavior -->
<button
v-bind="getToggleProps()"
class="rounded px-4 py-2"
:class="isOn ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-800'"
>
{{ isOn ? 'On' : 'Off' }}
</button>
</template>
The library does not render a <button>. It returns an object of attributes that you spread onto your own <button>. The data-state="on" or data-state="off" attribute that the getter includes becomes a styling hook.
This is the core of the headless contract: the library owns behavior, you own the element. The element receiving the Tailwind classes is the same element receiving the accessibility attributes, so there is no gap between what the user sees and what the screen reader announces.
b. Composable-Backed State and Renderless Mode
Headless libraries that follow the Vuetify0 model separate logic into composables and provide thin components as delivery mechanisms. A component’s role is to connect the composable to a slot; the composable itself can be used independently for cases where the component wrapper is too rigid .
A selection composable, for example, manages the state of a list of items, tracks which item is selected, and exposes methods like select(), next(), and prev(). The component wrapper renders a default structure, but you can bypass it and use the composable directly if you need a different DOM shape.
// Using a headless selection composable directly
import { useSelection } from 'some-headless-lib'
const { selectedId, selectedIndex, select, next, prev } = useSelection({
items: ['apple', 'banana', 'cherry']
})
The renderless mode takes this further: the component renders no wrapper element at all, only slot content. This is useful when you need complete control over the DOM structure but still want the behavior wiring .
<!-- Renderless mode: the component renders only the slot -->
<HeadlessToggle renderless>
<template #default="{ attrs, isOn }">
<!-- You provide the entire element tree -->
<div class="flex items-center gap-2">
<span class="text-sm">{{ isOn ? 'Active' : 'Inactive' }}</span>
<button v-bind="attrs" class="rounded p-1">
<span :class="isOn ? 'bg-green-500' : 'bg-gray-300'" class="block h-4 w-8 rounded-full" />
</button>
</div>
</template>
</HeadlessToggle>
The choice between using a component wrapper and a composable comes down to whether you need slot-based composition. If you are building a single element with behavior, a composable is lighter. If you are building a compound component—a dialog with a trigger, overlay, and content panel that need to coordinate—a component wrapper with slots is more appropriate .
c. Tailwind Integration and the v4 Configuration
Tailwind CSS v4 changes how configuration works: there is no tailwind.config.js by default, and theme tokens are defined in CSS using the @theme directive . This affects headless integration in two ways: how you style components conditionally, and how you configure Tailwind to scan headless component packages.
Styling with data attributes. Headless libraries expose state through data-* attributes rather than class names. Tailwind v4 supports data attribute variants natively, so you can write styles that respond to state without any JavaScript conditional logic in the template.
<!-- Styling based on headless data attributes -->
<button
v-bind="getToggleProps()"
class="rounded px-4 py-2 data-[state=on]:bg-green-600 data-[state=on]:text-white data-[state=off]:bg-gray-200"
>
Toggle
</button>
The data-[state=on]: variant applies the utility only when the element has data-state="on". This is the headless pattern in its purest form: the library sets the attribute, Tailwind styles it, and the template contains no conditional logic.
Tailwind v4 with component packages. When headless or styled components live in node_modules, Tailwind v4 does not scan them by default. This is a deliberate change from v3: Tailwind no longer scans node_modules automatically because doing so would include every utility from every package, defeating tree-shaking . You must explicitly point Tailwind at the component sources using @source.
/* main.css — Tailwind v4 entry with headless component scanning */
@import "tailwindcss";
/* Point Tailwind at the headless component package */
/* The path is relative to this CSS file */
@source "../node_modules/@some-headless-lib/dist";
/* Define your design tokens */
@theme {
--color-brand: #16a34a;
--color-surface: #ffffff;
}
Without the @source directive, the utility classes used inside the headless package’s markup are not generated, and the components render unstyled or partially styled . This is the most common Tailwind v4 integration failure.
The @reference directive for SFC styles. If you use @apply inside a Vue single-file component’s <style> block with Tailwind v4 custom tokens, the block needs a @reference to the main CSS file. Without it, the custom theme variables are not visible to the @apply directive .
<style scoped>
@reference "../assets/main.css";
.headless-button {
@apply bg-brand text-white px-4 py-2 rounded-lg;
}
</style>
For most headless usage, this is unnecessary because Tailwind classes are applied directly in the template. But when you extract a reusable class with @apply, the reference is required.
Complete Example Session
// ============================================
// PART 1: PROJECT STRUCTURE
// ============================================
// src/
// ├── components/
// │ └── ToggleSwitch.vue → Headless toggle with Tailwind
// ├── composables/
// │ └── useToggle.ts → Behavior logic (if building your own)
// ├── main.css → Tailwind v4 entry with @source
// └── App.vue
// ============================================
// PART 2: TAILWIND V4 ENTRY WITH HEADLESS SOURCE
// ============================================
// src/main.css
@import "tailwindcss";
/* Scan the headless library for utility classes */
/* Path is relative to this CSS file */
@source "../node_modules/@radix-vue/vue/dist";
/* Custom theme tokens */
@theme {
--color-brand: oklch(0.55 0.15 250);
--color-surface: #ffffff;
}
// ============================================
// PART 3: HEADLESS COMPONENT USAGE
// ============================================
// src/components/ToggleSwitch.vue
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'radix-vue'
import { ref } from 'vue'
// Controlled state: the parent owns the value
const enabled = ref(false)
</script>
<template>
<!-- Radix provides behavior; Tailwind provides styling -->
<!-- data-state is set by Radix on the root -->
<SwitchRoot
v-model="enabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors data-[state=checked]:bg-brand data-[state=unchecked]:bg-gray-300"
>
<!-- The thumb translates when checked -->
<SwitchThumb
class="block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow transition-transform data-[state=checked]:translate-x-5"
/>
</SwitchRoot>
</template>
// ============================================
// PART 4: SLOT PROPS PATTERN
// ============================================
// When the library exposes attrs via slot props
<script setup lang="ts">
import { HeadlessDisclosure } from 'some-headless-lib'
</script>
<template>
<HeadlessDisclosure>
<template #default="{ attrs, isOpen, toggle }">
<!-- attrs includes aria-expanded, aria-controls, onClick -->
<button
v-bind="attrs"
class="rounded border px-4 py-2"
:class="isOpen ? 'bg-brand text-white' : 'bg-white'"
@click="toggle"
>
{{ isOpen ? 'Close' : 'Open' }}
</button>
</template>
</HeadlessDisclosure>
</template>
// ============================================
// PART 5: COMPOSABLE DIRECT USAGE
// ============================================
// When you need behavior without the component wrapper
<script setup lang="ts">
import { useDisclosure } from 'some-headless-lib'
const { isOpen, toggle, getButtonProps, getPanelProps } = useDisclosure()
</script>
<template>
<div>
<!-- Spread the getter's result onto your own elements -->
<button v-bind="getButtonProps()" class="font-semibold">
Section title
</button>
<div v-bind="getPanelProps()" class="mt-2">
<slot />
</div>
</div>
</template>
// ============================================
// PART 6: DATA ATTRIBUTE STYLING
// ============================================
// Headless state is exposed as data-* attributes
// Tailwind styles them without JavaScript conditionals
<template>
<button
v-bind="getTriggerProps()"
class="rounded px-3 py-1.5 text-sm font-medium
data-[state=open]:bg-brand data-[state=open]:text-white
data-[state=closed]:bg-gray-100 data-[state=closed]:text-gray-700"
>
Menu
</button>
</template>
// ============================================
// PART 7: FOCUS AND DISABLED STYLING
// ============================================
// Headless libraries set disabled and focus attributes
<template>
<button
v-bind="getTriggerProps()"
class="rounded px-4 py-2
focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand
disabled:cursor-not-allowed disabled:opacity-50"
>
Submit
</button>
</template>
// ============================================
// PART 8: COMPOUND COMPONENT WITH CONTEXT
// ============================================
// Compound components share state through provide/inject
// The parent provides, children consume
<script setup lang="ts">
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from 'radix-vue'
</script>
<template>
<TabsRoot default-value="tab1">
<TabsList class="flex gap-1 border-b">
<TabsTrigger value="tab1" class="px-4 py-2 data-[state=active]:border-b-2 data-[state=active]:border-brand">
First
</TabsTrigger>
<TabsTrigger value="tab2" class="px-4 py-2 data-[state=active]:border-b-2 data-[state=active]:border-brand">
Second
</TabsTrigger>
</TabsList>
<TabsContent value="tab1" class="p-4">First panel</TabsContent>
<TabsContent value="tab2" class="p-4">Second panel</TabsContent>
</TabsRoot>
</template>
// ============================================
// PART 9: BUILDING A HEADLESS COMPOSABLE
// ============================================
// src/composables/useToggle.ts — your own headless logic
import { ref, computed } from 'vue'
export function useToggle(initial = false) {
const value = ref(initial)
const toggle = () => { value.value = !value.value }
const set = (v: boolean) => { value.value = v }
// getter returns attributes to spread onto an element
const getToggleProps = () => ({
'aria-pressed': value.value,
'data-state': value.value ? 'on' : 'off',
onClick: toggle
})
return { value, toggle, set, getToggleProps }
}
// ============================================
// PART 10: USING THE CUSTOM COMPOSABLE
// ============================================
// src/components/CustomToggle.vue
<script setup lang="ts">
import { useToggle } from '../composables/useToggle'
const { value: isOn, getToggleProps } = useToggle(false)
</script>
<template>
<button
v-bind="getToggleProps()"
class="rounded px-4 py-2 font-medium
data-[state=on]:bg-green-600 data-[state=on]:text-white
data-[state=off]:bg-gray-200 data-[state=off]:text-gray-800"
>
{{ isOn ? 'On' : 'Off' }}
</button>
</template>
The ten parts trace the headless pattern from Tailwind configuration through library usage to building your own composable. The constant theme is separation: the library sets attributes, Tailwind styles those attributes, and the template contains no visual logic beyond class selection.
Quick Reference
Headless Integration Patterns
| Pattern | Mechanism | When to Use |
|---|---|---|
| Slot props | v-bind="attrs" on your element | Component wrapper exposes behavior |
| Renderless | Component renders only slot | Full DOM control needed |
| Composable direct | useToggle() returns getters | Behavior without component overhead |
| Data attributes | data-state="on" | Styling state without JS conditionals |
| Compound parts | Root/Trigger/Content | Multi-part components with shared context |
Tailwind v4 Directives
| Directive | Purpose | Example |
|---|---|---|
@import "tailwindcss" | Load Tailwind | Entry CSS |
@source | Scan package for utilities | @source "../node_modules/lib" |
@theme | Define design tokens | --color-brand: #16a34a |
@reference | Access theme in SFC <style> | @reference "../main.css" |
Headless State Attributes
| Attribute | Set By | Styled With |
|---|---|---|
data-state | Library | data-[state=open]: |
aria-expanded | Library | Accessible name queries |
disabled | Library / prop | disabled: variant |
data-disabled | Library | data-[disabled]: variant |
Best Practices
✅ Do This:
<!-- Spread the headless attrs onto your styled element -->
<button v-bind="getToggleProps()" class="rounded px-4 py-2"> <!-- ✅ -->
/* Point Tailwind at headless packages in v4 */
@source "../node_modules/@headless-lib/dist"; /* ✅ */
<!-- Style state with data attribute variants -->
class="data-[state=open]:bg-brand data-[state=closed]:bg-gray-100" <!-- ✅ -->
// Prefer composables for pure logic reuse
const { isOpen, toggle } = useDisclosure() // ✅
<!-- Use compound components for multi-part widgets -->
<TabsRoot><TabsList><TabsTrigger value="a" /></TabsList></TabsRoot> <!-- ✅ -->
<!-- Keep Tailwind classes in the template, not in a separate file -->
<button class="px-4 py-2 bg-brand text-white"> <!-- ✅ -->
❌ Don’t Do This:
<!-- Wrap a headless component and add styles to the wrapper only -->
<div class="bg-blue-600"><HeadlessButton /></div> <!-- ❌ -->
/* Assume Tailwind v4 scans node_modules automatically */
/* No @source directive present */ /* ❌ */
<!-- Use JavaScript conditionals for state that data attributes expose -->
:class="isOpen ? 'bg-brand' : 'bg-gray-200'" <!-- ❌ -->
// Build a renderless component for logic that needs no slot composition
// A composable would have less overhead // ❌
<!-- Hardcode ARIA attributes that the headless library manages -->
<button aria-expanded="true"> <!-- ❌ -->
/* Use @apply with custom tokens without @reference in SFC */
.btn { @apply bg-brand; } /* brand is not defined here */ /* ❌ */
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Headless component renders unstyled | Tailwind v4 does not scan node_modules by default | Add @source pointing to the package |
| State styling does not apply | Using class conditionals instead of data variants | Use data-[state=x]: syntax |
@apply with custom tokens fails in SFC | Missing @reference to main CSS | Add @reference "../main.css" in <style> |
| Slot props not spread correctly | Applying attributes to a wrapper, not the interactive element | Spread attrs onto the actual <button> |
| Compound component context missing | Provider not wrapping children | Ensure root wraps all parts |
| Composable called outside setup | Lost Nuxt/Vue context | Call within setup or a plugin |
Real-World Examples
1. Tailwind v4 Source Directive for Headless Library
@import "tailwindcss";
@source "../node_modules/@radix-vue/vue/dist";
2. Data Attribute Styling for Dialog
<DialogContent class="data-[state=open]:animate-in data-[state=closed]:animate-out" />
3. Slot Props on a Headless Trigger
<template #default="{ attrs }">
<button v-bind="attrs" class="px-4 py-2">Open</button>
</template>
4. Composable for Toggle State
const { value, toggle, getToggleProps } = useToggle(false)
5. Renderless Component Usage
<HeadlessToggle renderless>
<template #default="{ attrs, isOn }">
<button v-bind="attrs">{{ isOn }}</button>
</template>
</HeadlessToggle>
6. Compound Tabs with Tailwind
<TabsTrigger value="tab1" class="data-[state=active]:border-b-2 data-[state=active]:border-brand">
7. Disabled State Styling
<button v-bind="getTriggerProps()" class="disabled:opacity-50 disabled:cursor-not-allowed">
8. Focus Visible Styling
<button v-bind="getTriggerProps()" class="focus-visible:outline-2 focus-visible:outline-brand">
9. Custom Composable with Data Attributes
const getToggleProps = () => ({
'data-state': value.value ? 'on' : 'off',
onClick: toggle
})
10. @reference for SFC Styles
<style scoped>
@reference "../main.css";
.btn { @apply bg-brand text-white; }
</style>
Visual
The Headless Contract
┌──────────────────────────────────────────────────────────────┐
│ HEADLESS LIBRARY YOUR TEMPLATE │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ State logic │ │ <button │ │
│ │ (open/closed, │ │ v-bind="attrs"│ │
│ │ selected, │──attrs───►│ class="px-4 │ │
│ │ disabled) │ │ bg-brand"> │ │
│ └─────────────────┘ │ Toggle │ │
│ │ </button> │ │
│ ┌─────────────────┐ └─────────────────┘ │
│ │ Accessibility │ │
│ │ (ARIA, focus, │ Tailwind classes │
│ │ keyboard) │ applied directly │
│ └─────────────────┘ to the element │
│ │
│ The library never renders the button. │
│ You never write the behavior. │
│ The same element receives both. │
│ │
└──────────────────────────────────────────────────────────────┘
Tailwind v4 Source Resolution
┌──────────────────────────────────────────────────────────────┐
│ YOUR PROJECT │
│ │
│ src/main.css │
│ ┌─────────────────────────────────────────┐ │
│ │ @import "tailwindcss"; │ │
│ │ │ │
│ │ @source "../node_modules/@lib/dist"; │ │
│ │ │ │ │
│ │ └──► Tailwind scans these files │ │
│ │ for utility class names │ │
│ └─────────────────────────────────────────┘ │
│ │
│ Without @source: │
│ ┌─────────────────────────────────────────┐ │
│ │ @import "tailwindcss"; │ │
│ │ │ │
│ │ (no @source) │ │
│ │ │ │
│ │ node_modules is NOT scanned │ │
│ │ Headless component classes are MISSING │ │
│ └─────────────────────────────────────────┘ │
│ │
│ Tailwind v4 requires explicit @source for packages. │
│ This preserves tree-shaking. │
│ │
└──────────────────────────────────────────────────────────────┘
State Styling Without JavaScript
┌──────────────────────────────────────────────────────────────┐
│ HEADLESS COMPONENT SETS: │
│ <button data-state="open" aria-expanded="true"> │
│ │
│ TAILWIND READS THE ATTRIBUTE: │
│ class="data-[state=open]:bg-brand" │
│ │
│ ┌─────────────────┐ │
│ │ data-state=open │────► bg-brand applied │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ data-state=closed│────► bg-brand NOT applied │
│ └─────────────────┘ │
│ │
│ No :class binding, no computed property. │
│ The library sets the attribute; Tailwind styles it. │
│ │
└──────────────────────────────────────────────────────────────┘
Composable vs Component Decision
┌──────────────────────────────────────────────────────────────┐
│ NEED BEHAVIOR? │
│ │
│ ┌─────────────────┐ │
│ │ Single element │────► COMPOSABLE │
│ │ No slot │ useToggle() │
│ │ composition │ returns getters │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Multiple parts │────► COMPONENT │
│ │ Slot needed │ <Root><Trigger> │
│ │ Shared context │ provide/inject │
│ └─────────────────┘ │
│ │
│ Composables: less overhead, no component instance. │
│ Components: slot composition, compound patterns. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Headless principle | Behavior without styles; you own every visual decision |
| Primary mechanism | Slot props with attrs object spread onto elements |
| State styling | data-* attributes with Tailwind variants |
| Composable advantage | No component instance overhead for pure logic |
| Component advantage | Slot composition, compound parts, shared context |
| Tailwind v4 change | No auto-scan of node_modules; use @source |
@source directive | Points Tailwind at headless package sources |
@reference directive | Enables custom tokens in SFC <style> with @apply |
| Data variant syntax | data-[state=open]:bg-brand |
Key takeaways:
- Headless means unstyled, not unopinionated about behavior. The library guarantees accessibility, keyboard navigation, and state management. You guarantee the appearance. The boundary is the element: the library provides attributes, you provide classes.
- Slot props are the integration point. Headless components expose an
attrsobject or agetXProps()function. Spread it onto your styled element. The same element receives both the Tailwind classes and the ARIA attributes, so there is no gap between appearance and accessibility. - Data attributes replace conditional logic. A headless library sets
data-state="open". Tailwind styles it withdata-[state=open]:bg-brand. The template does not need:classbindings for state that the library already tracks. - Composables are lighter than renderless components for pure logic. If you need behavior on a single element and no slot composition, use the composable directly. Use component wrappers when multiple parts must share context.
- Tailwind v4 requires explicit
@sourcefor headless packages. The framework no longer scansnode_modulesautomatically. Without the directive, utility classes used inside the headless library’s markup are not generated, and components render unstyled. @referenceis required for custom tokens in@applywithin SFC styles. If you extract a class with@applythat uses a@themetoken, the<style>block needs a reference to the main CSS file.
Remember: Headless components and Tailwind are complementary because they solve different halves of the same problem. The headless library answers “how does this behave?” Tailwind answers “how does this look?” Neither answer lives in the other’s territory. The integration point is the element: the library hands you attributes, you hand it classes, and the browser renders one element that is both accessible and styled. The configuration that makes this work in Tailwind v4 is @source—without it, the library’s utility classes never reach the build, and the headless component appears broken when it is merely ungenerated. When the source directive is correct and the attributes are spread correctly, the result is a component that is as accessible as a library component and as customizable as plain HTML.
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!