| |

Vue.js 80 🟢 Headless Component Patterns and Integration with Tailwind CSS

⏱️ Estimated read: 17 min | |

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

PatternMechanismWhen to Use
Slot propsv-bind="attrs" on your elementComponent wrapper exposes behavior
RenderlessComponent renders only slotFull DOM control needed
Composable directuseToggle() returns gettersBehavior without component overhead
Data attributesdata-state="on"Styling state without JS conditionals
Compound partsRoot/Trigger/ContentMulti-part components with shared context

Tailwind v4 Directives

DirectivePurposeExample
@import "tailwindcss"Load TailwindEntry CSS
@sourceScan package for utilities@source "../node_modules/lib"
@themeDefine design tokens--color-brand: #16a34a
@referenceAccess theme in SFC <style>@reference "../main.css"

Headless State Attributes

AttributeSet ByStyled With
data-stateLibrarydata-[state=open]:
aria-expandedLibraryAccessible name queries
disabledLibrary / propdisabled: variant
data-disabledLibrarydata-[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

PitfallWhy It HappensFix
Headless component renders unstyledTailwind v4 does not scan node_modules by defaultAdd @source pointing to the package
State styling does not applyUsing class conditionals instead of data variantsUse data-[state=x]: syntax
@apply with custom tokens fails in SFCMissing @reference to main CSSAdd @reference "../main.css" in <style>
Slot props not spread correctlyApplying attributes to a wrapper, not the interactive elementSpread attrs onto the actual <button>
Compound component context missingProvider not wrapping childrenEnsure root wraps all parts
Composable called outside setupLost Nuxt/Vue contextCall 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

ItemValue
Headless principleBehavior without styles; you own every visual decision
Primary mechanismSlot props with attrs object spread onto elements
State stylingdata-* attributes with Tailwind variants
Composable advantageNo component instance overhead for pure logic
Component advantageSlot composition, compound parts, shared context
Tailwind v4 changeNo auto-scan of node_modules; use @source
@source directivePoints Tailwind at headless package sources
@reference directiveEnables custom tokens in SFC <style> with @apply
Data variant syntaxdata-[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 attrs object or a getXProps() 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 with data-[state=open]:bg-brand. The template does not need :class bindings 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 @source for headless packages. The framework no longer scans node_modules automatically. Without the directive, utility classes used inside the headless library’s markup are not generated, and components render unstyled.
  • @reference is required for custom tokens in @apply within SFC styles. If you extract a class with @apply that uses a @theme token, 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.

Was this helpful?


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!