Styling Headless Components
v0 components are headless—they provide behavior and accessibility, you provide the styling. This guide covers two first-class patterns for styling based on component state.
Two Approaches
v0 exposes component state in two ways:
| Approach | Syntax | Best For |
|---|---|---|
| Data Attributes | data-[selected]:bg-primary | Simple states, utility-first CSS |
| Slot Props | :class="{ 'bg-primary': isSelected }" | Complex conditions, computed styles |
Both approaches work with any CSS framework. Data attributes are set automatically via the attrs object—slot props give you reactive booleans for template logic.
Start with data attributes. They’re simpler and keep styling in CSS where it belongs. Reach for slot props only when you need complex conditional logic.
Data Attributes Reference
All v0 components expose state via data-* attributes in the attrs object:
| Component | Attributes | Notes |
|---|---|---|
| Selection Single Group | data-selecteddata-disabled | Set on items |
| Group | data-mixed | Tri-state only |
| Tabs | data-selecteddata-disabled | Tab items |
| ExpansionPanel | data-selecteddata-disabled | Activator element |
| Checkbox | data-statedata-disabled | checked unchecked indeterminate |
| Radio | data-statedata-disabled | checked unchecked |
| Popover | data-popover-open | Activator element |
| Dialog | data-dialog-open | Activator element |
Attributes are only present when true. Use [data-selected] not [data-selected="true"].
Styling with Data Attributes
Data attribute selectors let you style based on state purely in CSS. This works with Tailwind, UnoCSS, plain CSS, or any framework supporting attribute selectors.
Tailwind / UnoCSS
Use the data-[attr]: variant↗ to apply classes when an attribute is present:
Selected: apple
<script setup lang="ts">
import { Tabs } from '@vuetify/v0'
import { shallowRef } from 'vue'
const selected = shallowRef('apple')
const items = ['Apple', 'Banana', 'Cherry']
</script>
<template>
<Tabs.Root v-model="selected">
<Tabs.List class="flex flex-wrap gap-2" label="Fruit selection">
<Tabs.Item
v-for="item in items"
:key="item"
class="px-3 py-1.5 border rounded text-sm cursor-pointer select-none
bg-surface border-divider text-on-surface
hover:border-primary/50
data-[selected]:bg-primary data-[selected]:border-primary data-[selected]:text-on-primary
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
:value="item"
>
{{ item }}
</Tabs.Item>
</Tabs.List>
</Tabs.Root>
<p class="mt-4 text-sm text-on-surface-variant">
Selected: {{ selected }}
</p>
</template>
CSS Modules
Target data attributes in your module styles↗:
Selected: apple
<script setup lang="ts">
import { Tabs } from '@vuetify/v0'
import { shallowRef } from 'vue'
const selected = shallowRef('apple')
const items = ['Apple', 'Banana', 'Cherry']
</script>
<template>
<Tabs.Root v-model="selected">
<Tabs.List class="flex flex-wrap gap-2" label="Fruit selection">
<Tabs.Item
v-for="item in items"
:key="item"
:class="$style.btn"
:value="item"
>
{{ item }}
</Tabs.Item>
</Tabs.List>
</Tabs.Root>
<p class="mt-4 text-sm text-on-surface-variant">
Selected: {{ selected }}
</p>
</template>
<style module>
.btn {
background: var(--v0-surface);
color: var(--v0-on-surface);
padding: 0.375rem 0.75rem;
border-radius: 0.25rem;
border: 1px solid var(--v0-divider);
}
.btn[data-selected] {
background: var(--v0-primary);
color: var(--v0-on-primary);
border-color: var(--v0-primary);
}
</style>
Plain CSS
Standard attribute selectors↗ work in any stylesheet:
/* Single state */
[data-selected] {
background: var(--v0-primary);
color: var(--v0-on-primary);
}
/* Compound states */
[data-selected][data-disabled] {
background: color-mix(in srgb, var(--v0-primary) 60%, transparent);
cursor: not-allowed;
} CSS Modules pair well with theme tokens. Use var(--v0-*) for consistent theming across your app.
Styling with Slot Props
Slot props provide reactive state as JavaScript booleans. Use them when you need:
Multiple dependent states:
isSelected && !isDisabled && isFocusedComputed class names from external logic
Conditional rendering (icons, badges, text)
Dynamic inline styles (progress bars, animations)
Selected: Banana
<script setup lang="ts">
import { Selection } from '@vuetify/v0'
import { shallowRef } from 'vue'
const selected = shallowRef<string[]>(['Banana'])
const items = ['Apple', 'Banana', 'Cherry']
</script>
<template>
<Selection.Root v-model="selected" multiple>
<div class="flex flex-wrap gap-2">
<Selection.Item
v-for="item in items"
:key="item"
v-slot="{ isSelected, attrs, toggle }"
:value="item"
>
<button
v-bind="attrs"
:class="[
'px-3 py-1.5 border rounded text-sm',
isSelected
? 'bg-primary border-primary text-on-primary'
: 'bg-surface border-divider text-on-surface hover:border-primary/50'
]"
@click="toggle"
>
{{ item }}
<span v-if="isSelected" class="ml-1">✓</span>
</button>
</Selection.Item>
</div>
</Selection.Root>
<p class="mt-4 text-sm text-on-surface-variant">
Selected: {{ selected.length > 0 ? selected.join(', ') : 'None' }}
</p>
</template>
Always spread attrs on your interactive element. It contains ARIA attributes required for accessibility, plus data attributes for CSS styling.
Advanced Patterns
Transitions
Add CSS transitions for smooth state changes:
Selected: fade
<script setup lang="ts">
import { Tabs } from '@vuetify/v0'
import { shallowRef } from 'vue'
const selected = shallowRef('fade')
</script>
<template>
<Tabs.Root v-model="selected">
<Tabs.List class="flex flex-wrap gap-3" label="Transition examples">
<Tabs.Item
class="px-4 py-2 border rounded text-sm cursor-pointer select-none
bg-surface border-divider text-on-surface
transition-colors duration-200 ease-out
data-[selected]:bg-primary data-[selected]:border-primary data-[selected]:text-on-primary"
value="fade"
>
Fade
</Tabs.Item>
<Tabs.Item
class="px-4 py-2 border rounded text-sm cursor-pointer select-none
bg-surface border-divider text-on-surface
transition-all duration-200 ease-out
data-[selected]:bg-primary data-[selected]:border-primary data-[selected]:text-on-primary
data-[selected]:scale-105"
value="scale"
>
Scale
</Tabs.Item>
<Tabs.Item
class="px-4 py-2 border rounded text-sm cursor-pointer select-none
bg-surface border-divider text-on-surface
transition-all duration-200 ease-out
data-[selected]:bg-primary data-[selected]:border-primary data-[selected]:text-on-primary
data-[selected]:shadow-lg data-[selected]:shadow-primary/25"
value="shadow"
>
Shadow
</Tabs.Item>
</Tabs.List>
</Tabs.Root>
<p class="mt-4 text-sm text-on-surface-variant">
Selected: {{ selected }}
</p>
</template>
Focus States
Combine focus-visible with selection state for keyboard navigation feedback:
<template>
<Tabs.Item
class="focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2
data-[selected]:bg-primary"
:value="item"
>
{{ item }}
</Tabs.Item>
</template>Compound States
Style combinations using CSS attribute selectors:
Selected: disabled-selected
<script setup lang="ts">
import { Selection } from '@vuetify/v0'
import { shallowRef } from 'vue'
const selected = shallowRef(['disabled-selected'])
</script>
<template>
<Selection.Root v-model="selected" multiple>
<div class="flex flex-wrap gap-2">
<!-- Normal -->
<Selection.Item v-slot="{ isSelected, attrs, toggle }" value="normal">
<button
v-bind="attrs"
class="px-3 py-1.5 rounded text-sm border bg-surface border-divider text-on-surface hover:border-primary/50"
:class="isSelected && 'bg-primary! border-primary! text-on-primary!'"
@click="toggle"
>
Normal
</button>
</Selection.Item>
<!-- Disabled -->
<Selection.Item v-slot="{ attrs }" disabled value="disabled">
<button
v-bind="attrs"
class="px-3 py-1.5 rounded text-sm border bg-surface border-divider text-on-surface opacity-40 cursor-not-allowed!"
>
Disabled
</button>
</Selection.Item>
<!-- Disabled + Selected -->
<Selection.Item v-slot="{ attrs }" disabled value="disabled-selected">
<button
v-bind="attrs"
class="px-3 py-1.5 rounded text-sm border bg-primary/20 border-primary text-primary cursor-not-allowed! opacity-60"
>
Disabled Selected
</button>
</Selection.Item>
</div>
</Selection.Root>
<p class="mt-4 text-sm text-on-surface-variant">
Selected: {{ selected.length > 0 ? selected.join(', ') : 'None' }}
</p>
</template>
Parent-Child Styling
Use Tailwind’s group utilities to style children based on parent state:
<template>
<Tabs.Item class="group inline-flex items-center gap-2" :value="item">
<span class="group-data-[selected]:font-bold">{{ item }}</span>
<CheckIcon class="size-4 opacity-0 group-data-[selected]:opacity-100 transition-opacity" />
</Tabs.Item>
</template>When to Use Which
When in doubt, start with data attributes. Refactor to slot props only if the conditional logic becomes unwieldy.