Building Frameworks
When building a component framework, you’re often reimplementing the same patterns: selection state, keyboard navigation, form validation, and focus management. v0 provides these behaviors as headless primitives so you can focus on what makes your framework unique—its design language.
Getting Started
New Project
Use vuetify0 create to scaffold a new project with v0 pre-configured:
pnpm create vuetify0npm create vuetify0yarn create vuetify0bun create vuetify0Existing Project
Add v0 to an existing Vue project:
pnpm add @vuetify/v0npm install @vuetify/v0yarn add @vuetify/v0bun add @vuetify/v0v0 uses subpath exports↗ for tree-shaking:
// Import everything
import { createSingle, Atom } from '@vuetify/v0'
// Or import specific modules
import { createSingle } from '@vuetify/v0/composables'
import { Atom } from '@vuetify/v0/components'
import type { ID } from '@vuetify/v0/types'
import { isObject } from '@vuetify/v0/utilities'
import { IN_BROWSER } from '@vuetify/v0/constants'Two Integration Patterns
This guide covers two approaches:
| Pattern | When to Use |
|---|---|
| Pattern A: Behavior-Focused | Need complex state (selection, navigation, validation) with full rendering control |
| Pattern B: Component Wrappers | Building styled components on top of v0’s headless primitives |
v0’s composables are completely headless—they manage state and behavior without any DOM assumptions. This makes them ideal for building design systems that need complete control over markup and styling.
Core Concepts
Direct vs Context APIs
v0 composables offer two API surfaces. The direct API creates standalone instances—perfect for component-local state. The context API uses Vue’s provide/inject↗ for sharing state across component trees.
// Direct instance (component-local)
const tabs = createSingle({ mandatory: true })
// Context trinity (for dependency injection)
const [useTabs, provideTabs, defaultTabs] = createSingleContext()Start with the direct API. Only reach for contexts when you need to share state between parent and child components that can’t communicate via props.
The Ticket System
When you register items with a v0 registry, you get back “tickets”—plain objects that contain reactive properties and bound methods. Properties like isSelected are Vue refs that update automatically when selection state changes.
tabs.register({ id: 'home', value: 'Home' })
const ticket = tabs.get('home')
ticket.id // 'home' (static)
ticket.value // 'Home' (static)
ticket.index // 0 (static, position in registry)
ticket.isSelected // Ref<boolean> - reactive!
ticket.isSelected.value // true/false - access the value
ticket.toggle() // Toggle selection
ticket.select() // Select this itemTemplate Iteration
Registry internals aren’t directly exposed for template iteration. useProxyRegistry wraps a registry and provides reactive arrays that update when items change:
<script setup lang="ts">
import { createSingle, useProxyRegistry } from '@vuetify/v0/composables'
const tabs = createSingle({ mandatory: 'force', events: true })
const proxy = useProxyRegistry(tabs)
// proxy.values is a reactive array that updates when registry changes
// proxy.keys and proxy.entries also available
</script>
<template>
<button v-for="tab in proxy.values" :key="tab.id" @click="tab.toggle">
{{ tab.value }}
</button>
</template> The events: true option is required. useProxyRegistry listens for registry events to invalidate its cache.
Pattern A: Behavior-Focused
Use v0’s composables directly when you need complex state management—selection, navigation, validation—but want full control over rendering.
Profile
Content for the profile tab.
Settings
Content for the settings tab.
Billing
Content for the billing tab.
<script setup lang="ts">
import { createSingle, useProxyRegistry } from '@vuetify/v0'
import { useId } from 'vue'
const uid = useId()
const tabs = createSingle({ mandatory: 'force', events: true })
const proxy = useProxyRegistry(tabs)
tabs.onboard([
{ id: 'profile', value: 'Profile' },
{ id: 'settings', value: 'Settings' },
{ id: 'billing', value: 'Billing' },
])
</script>
<template>
<div class="w-full">
<div
class="flex gap-1 border-b border-divider"
role="tablist"
>
<button
v-for="tab in proxy.values"
:id="`${uid}-tab-${tab.id}`"
:key="tab.id"
:aria-controls="`${uid}-panel-${tab.id}`"
:aria-selected="tab.isSelected.value"
class="px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors"
:class="tab.isSelected.value
? 'border-primary text-primary'
: 'border-transparent text-on-surface-variant hover:text-on-surface hover:border-divider'"
role="tab"
:tabindex="tab.isSelected.value ? 0 : -1"
@click="tab.toggle"
>
{{ tab.value }}
</button>
</div>
<div class="p-4">
<div
v-for="tab in proxy.values"
:id="`${uid}-panel-${tab.id}`"
:key="tab.id"
:aria-labelledby="`${uid}-tab-${tab.id}`"
:hidden="!tab.isSelected.value"
role="tabpanel"
>
<h3 class="text-lg font-medium mb-2">{{ tab.value }}</h3>
<p class="text-on-surface-variant">
Content for the {{ String(tab.value).toLowerCase() }} tab.
</p>
</div>
</div>
</div>
</template>
Adding Keyboard Navigation
v0 composables handle state; you add the interaction layer:
Overview
This panel demonstrates keyboard navigation. Use arrow keys to move between tabs, Home/End to jump to first/last.
Features
This panel demonstrates keyboard navigation. Use arrow keys to move between tabs, Home/End to jump to first/last.
Pricing
This panel demonstrates keyboard navigation. Use arrow keys to move between tabs, Home/End to jump to first/last.
FAQ
This panel demonstrates keyboard navigation. Use arrow keys to move between tabs, Home/End to jump to first/last.
←→ Navigate HomeEnd Jump
<script setup lang="ts">
import { createSingle, useProxyRegistry } from '@vuetify/v0'
import { useId } from 'vue'
const uid = useId()
const tabs = createSingle({ mandatory: 'force', events: true })
const proxy = useProxyRegistry(tabs)
tabs.onboard([
{ id: 'overview', value: 'Overview' },
{ id: 'features', value: 'Features' },
{ id: 'pricing', value: 'Pricing' },
{ id: 'faq', value: 'FAQ' },
])
function onKeydown (event: KeyboardEvent) {
const items = Array.from(proxy.values)
const current = items.findIndex(t => t.isSelected.value)
let next = current
switch (event.key) {
case 'ArrowRight': {
next = current < items.length - 1 ? current + 1 : 0
break
}
case 'ArrowLeft': {
next = current > 0 ? current - 1 : items.length - 1
break
}
case 'Home': {
next = 0
break
}
case 'End': {
next = items.length - 1
break
}
default: {
return
}
}
if (next !== current) {
event.preventDefault()
const nextTab = items[next]
nextTab.select()
document.querySelector<HTMLButtonElement>(`#${CSS.escape(`${uid}-tab-${nextTab.id}`)}`)?.focus()
}
}
</script>
<template>
<div class="w-full">
<div
aria-label="Product information"
class="flex gap-1 bg-surface-variant rounded-lg p-1"
role="tablist"
@keydown="onKeydown"
>
<button
v-for="tab in proxy.values"
:id="`${uid}-tab-${tab.id}`"
:key="tab.id"
:aria-controls="`${uid}-panel-${tab.id}`"
:aria-selected="tab.isSelected.value"
class="flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-all"
:class="tab.isSelected.value
? 'bg-surface text-on-surface shadow-sm'
: 'text-on-surface-variant hover:text-on-surface'"
role="tab"
:tabindex="tab.isSelected.value ? 0 : -1"
@click="tab.toggle"
>
{{ tab.value }}
</button>
</div>
<div class="mt-4">
<div
v-for="tab in proxy.values"
:id="`${uid}-panel-${tab.id}`"
:key="tab.id"
:aria-labelledby="`${uid}-tab-${tab.id}`"
class="p-4 bg-surface border border-divider rounded-lg"
:hidden="!tab.isSelected.value"
role="tabpanel"
>
<h3 class="text-lg font-semibold mb-2">{{ tab.value }}</h3>
<p class="text-on-surface-variant">
This panel demonstrates keyboard navigation. Use arrow keys to move between tabs,
Home/End to jump to first/last.
</p>
</div>
</div>
<p class="mt-3 text-xs text-on-surface-variant">
<kbd class="px-1.5 py-0.5 bg-surface-variant rounded text-[10px]">←</kbd>
<kbd class="px-1.5 py-0.5 bg-surface-variant rounded text-[10px] ml-1">→</kbd>
Navigate
<kbd class="px-1.5 py-0.5 bg-surface-variant rounded text-[10px] ml-3">Home</kbd>
<kbd class="px-1.5 py-0.5 bg-surface-variant rounded text-[10px] ml-1">End</kbd>
Jump
</p>
</div>
</template>
Multi-Selection with Groups
Use createGroup for checkbox-style multi-selection with tri-state support:
<script setup lang="ts">
import { createGroup, useProxyRegistry } from '@vuetify/v0'
import { useId } from 'vue'
const uid = useId()
const accordion = createGroup({ events: true })
const proxy = useProxyRegistry(accordion)
accordion.onboard([
{ id: 'what', value: 'What is v0?' },
{ id: 'why', value: 'Why use v0?' },
{ id: 'how', value: 'How do I get started?' },
])
const content: Record<string, string> = {
what: 'v0 is a collection of headless UI primitives for Vue 3. It provides the logic and accessibility while you control the styling.',
why: 'v0 lets you build custom design systems without reinventing selection, navigation, and form logic. Focus on what makes your framework unique.',
how: 'Install @vuetify/v0, import the composables you need, and start building. Check the documentation for patterns and examples.',
}
</script>
<template>
<div class="w-full space-y-2">
<div class="flex items-center justify-between mb-4">
<span class="text-sm text-on-surface-variant">
{{ accordion.selectedIds.size }} of {{ accordion.size }} expanded
</span>
<div class="flex gap-2">
<button
class="px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 rounded transition-colors"
@click="accordion.selectAll"
>
Expand all
</button>
<button
class="px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 rounded transition-colors"
@click="accordion.unselectAll"
>
Collapse all
</button>
</div>
</div>
<div
v-for="item in proxy.values"
:key="item.id"
class="border border-divider rounded-lg overflow-hidden"
>
<button
:id="`${uid}-header-${item.id}`"
:aria-controls="`${uid}-panel-${item.id}`"
:aria-expanded="item.isSelected.value"
class="w-full flex items-center justify-between px-4 py-3 text-left font-medium hover:bg-surface-variant transition-colors"
@click="item.toggle"
>
<span>{{ item.value }}</span>
<svg
class="transition-transform duration-200"
:class="{ 'rotate-180': item.isSelected.value }"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
</svg>
</button>
<div
:id="`${uid}-panel-${item.id}`"
:aria-labelledby="`${uid}-header-${item.id}`"
class="px-4 pb-4 text-on-surface-variant"
:hidden="!item.isSelected.value"
role="region"
>
{{ content[item.id] }}
</div>
</div>
</div>
</template>
Pattern B: Component Wrappers
Wrap v0’s headless components with your design system’s styling. v0 handles behavior, accessibility, and keyboard navigation—you control the visual presentation.
Polymorphic Elements
The Atom component provides polymorphic rendering via the as prop—render as any HTML element while keeping your component’s API consistent:
<script setup lang="ts">
import type { AtomProps } from '@vuetify/v0'
import { Atom } from '@vuetify/v0'
import { shallowRef, toRef } from 'vue'
interface MyButtonProps extends AtomProps {
variant?: 'filled' | 'outlined' | 'text'
size?: 'sm' | 'md' | 'lg'
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
}
const {
as = 'button',
variant = 'filled',
size = 'md',
color = 'primary',
} = defineProps<MyButtonProps>()
const classes = toRef(() => [
'my-button',
`my-button--${variant}`,
`my-button--${size}`,
`my-button--${color}`,
])
// Demo state
const clicks = shallowRef(0)
</script>
<template>
<div class="space-y-6">
<!-- Component definition preview -->
<div class="p-4 bg-surface-variant rounded-lg">
<p class="text-xs text-on-surface-variant mb-3 font-mono">MyButton.vue</p>
<div class="flex flex-wrap gap-3">
<Atom
:as="as"
:class="classes"
@click="clicks++"
>
Click me ({{ clicks }})
</Atom>
<Atom
as="a"
class="my-button my-button--text my-button--md my-button--primary"
href="#"
@click.prevent
>
Link Button
</Atom>
<Atom
as="button"
class="my-button my-button--text my-button--md my-button--error"
>
Delete
</Atom>
</div>
</div>
<!-- Variant showcase -->
<div>
<p class="text-sm font-medium mb-2">Variants</p>
<div class="flex flex-wrap gap-2">
<Atom as="button" class="my-button my-button--filled my-button--md my-button--primary">Filled</Atom>
<Atom as="button" class="my-button my-button--outlined my-button--md my-button--primary">Outlined</Atom>
<Atom as="button" class="my-button my-button--text my-button--md my-button--primary">Text</Atom>
</div>
</div>
<!-- Size showcase -->
<div>
<p class="text-sm font-medium mb-2">Sizes</p>
<div class="flex flex-wrap items-center gap-2">
<Atom as="button" class="my-button my-button--filled my-button--sm my-button--primary">Small</Atom>
<Atom as="button" class="my-button my-button--filled my-button--md my-button--primary">Medium</Atom>
<Atom as="button" class="my-button my-button--filled my-button--lg my-button--primary">Large</Atom>
</div>
</div>
<!-- Color showcase -->
<div>
<p class="text-sm font-medium mb-2">Colors</p>
<div class="flex flex-wrap gap-2">
<Atom as="button" class="my-button my-button--filled my-button--md my-button--primary">Primary</Atom>
<Atom as="button" class="my-button my-button--filled my-button--md my-button--secondary">Secondary</Atom>
<Atom as="button" class="my-button my-button--filled my-button--md my-button--success">Success</Atom>
<Atom as="button" class="my-button my-button--filled my-button--md my-button--warning">Warning</Atom>
<Atom as="button" class="my-button my-button--filled my-button--md my-button--error">Error</Atom>
<Atom as="button" class="my-button my-button--filled my-button--md my-button--info">Info</Atom>
</div>
</div>
</div>
</template>
<style>
.my-button {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 500;
border-radius: 0.375rem;
transition: all 150ms;
cursor: pointer;
border: 1px solid transparent;
}
/* Sizes */
.my-button--sm { padding: 0.25rem 0.75rem; font-size: 0.75rem; }
.my-button--md { padding: 0.5rem 1rem; font-size: 0.875rem; }
.my-button--lg { padding: 0.75rem 1.5rem; font-size: 1rem; }
/* Filled variants */
.my-button--filled.my-button--primary { background: var(--v0-primary); color: var(--v0-on-primary); }
.my-button--filled.my-button--secondary { background: var(--v0-secondary); color: var(--v0-on-secondary); }
.my-button--filled.my-button--success { background: var(--v0-success); color: var(--v0-on-success); }
.my-button--filled.my-button--warning { background: var(--v0-warning); color: var(--v0-on-warning); }
.my-button--filled.my-button--error { background: var(--v0-error); color: var(--v0-on-error); }
.my-button--filled.my-button--info { background: var(--v0-info); color: var(--v0-on-info); }
.my-button--filled:hover { opacity: 0.9; }
/* Outlined variants */
.my-button--outlined { background: transparent; }
.my-button--outlined.my-button--primary { border-color: var(--v0-primary); color: var(--v0-primary); }
.my-button--outlined.my-button--secondary { border-color: var(--v0-secondary); color: var(--v0-secondary); }
.my-button--outlined.my-button--success { border-color: var(--v0-success); color: var(--v0-success); }
.my-button--outlined.my-button--warning { border-color: var(--v0-warning); color: var(--v0-warning); }
.my-button--outlined.my-button--error { border-color: var(--v0-error); color: var(--v0-error); }
.my-button--outlined.my-button--info { border-color: var(--v0-info); color: var(--v0-info); }
.my-button--outlined:hover { background: rgba(0, 0, 0, 0.05); }
/* Text variants */
.my-button--text { background: transparent; border-color: transparent; }
.my-button--text.my-button--primary { color: var(--v0-primary); }
.my-button--text.my-button--secondary { color: var(--v0-secondary); }
.my-button--text.my-button--success { color: var(--v0-success); }
.my-button--text.my-button--warning { color: var(--v0-warning); }
.my-button--text.my-button--error { color: var(--v0-error); }
.my-button--text.my-button--info { color: var(--v0-info); }
.my-button--text:hover { background: rgba(0, 0, 0, 0.05); }
</style>
The example uses BEM naming↗—a convention for organizing CSS in component libraries. Blocks (.my-button), elements (__icon), and modifiers (--filled, --primary) create predictable, collision-free class names.
Styling Headless Components
Wrap v0’s compound components with custom CSS. The components expose data attributes like data-state for styling different states:
Wrapping v0's ExpansionPanel with custom styling:
<script setup lang="ts">
import { ExpansionPanel } from '@vuetify/v0'
import { shallowRef } from 'vue'
const expanded = shallowRef<string[]>(['features'])
const cards = [
{
value: 'features',
title: 'Key Features',
content: 'Headless components, composables, and utilities for building design systems. Full TypeScript support with generic constraints.',
},
{
value: 'ssr',
title: 'SSR Ready',
content: 'Built for universal rendering. All composables are SSR-safe with hydration state management included.',
},
{
value: 'a11y',
title: 'Accessible',
content: 'ARIA attributes, keyboard navigation, and focus management built into every component.',
},
]
</script>
<template>
<div class="space-y-3">
<p class="text-sm text-on-surface-variant mb-4">
Wrapping v0's ExpansionPanel with custom styling:
</p>
<ExpansionPanel.Root v-model="expanded" class="space-y-3" multiple>
<ExpansionPanel.Item
v-for="card in cards"
:key="card.value"
v-slot="{ isSelected }"
class="my-card"
:value="card.value"
>
<ExpansionPanel.Header class="my-card__header">
<ExpansionPanel.Activator class="my-card__activator">
<span class="my-card__title">{{ card.title }}</span>
<svg
class="my-card__icon"
:class="{ 'rotate-180': isSelected }"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
</svg>
</ExpansionPanel.Activator>
</ExpansionPanel.Header>
<ExpansionPanel.Content class="my-card__content">
{{ card.content }}
</ExpansionPanel.Content>
</ExpansionPanel.Item>
</ExpansionPanel.Root>
</div>
</template>
<style>
.my-card {
border: 1px solid var(--v0-divider);
border-radius: 0.5rem;
overflow: hidden;
transition: border-color 150ms;
}
.my-card[data-selected] {
border-color: var(--v0-primary);
}
.my-card__header {
background: var(--v0-surface);
}
.my-card__activator {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 150ms;
}
.my-card__activator:hover {
background: var(--v0-surface-variant);
}
.my-card__title {
font-weight: 600;
color: var(--v0-on-surface);
}
.my-card__icon {
color: var(--v0-on-surface-variant);
transition: transform 200ms;
}
.my-card__content {
padding: 0 1rem 1rem;
color: var(--v0-on-surface-variant);
font-size: 0.875rem;
line-height: 1.5;
}
</style>
Form Components
v0’s form components handle focus, keyboard interaction, and ARIA attributes. Apply your styles via classes:
Wrapping v0's Checkbox with custom styling:
<script setup lang="ts">
import { Checkbox } from '@vuetify/v0'
import { shallowRef } from 'vue'
const selected = shallowRef<string[]>(['notifications', 'updates'])
const options = [
{
value: 'notifications',
title: 'Email notifications',
description: 'Receive emails about account activity',
},
{
value: 'marketing',
title: 'Marketing emails',
description: 'Receive tips, tutorials, and product updates',
},
{
value: 'updates',
title: 'Security updates',
description: 'Get notified about security and privacy changes',
},
]
</script>
<template>
<div class="space-y-4">
<p class="text-sm text-on-surface-variant mb-4">
Wrapping v0's Checkbox with custom styling:
</p>
<Checkbox.Group v-model="selected" class="space-y-3">
<label
v-for="option in options"
:key="option.value"
class="my-checkbox"
>
<Checkbox.Root
class="my-checkbox__root"
:value="option.value"
>
<Checkbox.Indicator class="my-checkbox__indicator">
<svg
fill="currentColor"
height="14"
viewBox="0 0 24 24"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
</svg>
</Checkbox.Indicator>
</Checkbox.Root>
<span class="my-checkbox__label">
<span class="my-checkbox__title">{{ option.title }}</span>
<span class="my-checkbox__description">{{ option.description }}</span>
</span>
</label>
</Checkbox.Group>
<div class="mt-4 p-3 bg-surface-variant rounded-lg text-xs text-on-surface-variant">
<strong>Selected:</strong> {{ selected.join(', ') || 'none' }}
</div>
</div>
</template>
<style>
.my-checkbox {
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
}
.my-checkbox__root {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
margin-top: 0.125rem;
border: 2px solid var(--v0-divider);
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 150ms;
background: var(--v0-surface);
}
.my-checkbox__root[data-state="checked"] {
background: var(--v0-primary);
border-color: var(--v0-primary);
}
.my-checkbox__root:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(var(--v0-primary-rgb, 25, 118, 210), 0.2);
}
.my-checkbox__indicator {
color: var(--v0-on-primary);
font-size: 0.875rem;
}
.my-checkbox__label {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.my-checkbox__title {
font-weight: 500;
color: var(--v0-on-surface);
font-size: 0.875rem;
}
.my-checkbox__description {
color: var(--v0-on-surface-variant);
font-size: 0.75rem;
}
</style>
Plugin Architecture
v0’s plugins follow Vue’s plugin pattern↗ with additional structure for namespaced context provision.
Access v0 plugins anywhere in your component tree:
useTheme()
useBreakpoints()
// plugins/index.ts
app.use(createThemePlugin({ ... }))
app.use(createBreakpointsPlugin({ ... }))
<script setup lang="ts">
import { useBreakpoints, useTheme } from '@vuetify/v0'
const theme = useTheme()
const breakpoints = useBreakpoints()
</script>
<template>
<div class="space-y-4">
<p class="text-sm text-on-surface-variant">
Access v0 plugins anywhere in your component tree:
</p>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Theme info -->
<div class="p-4 bg-surface border border-divider rounded-lg">
<h3 class="text-sm font-semibold mb-3">useTheme()</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-on-surface-variant">Current:</span>
<span class="font-mono">{{ theme.selectedId?.value ?? 'none' }}</span>
</div>
<div class="flex justify-between">
<span class="text-on-surface-variant">Dark mode:</span>
<span class="font-mono">{{ theme.isDark?.value ?? false }}</span>
</div>
<div class="flex gap-2 mt-3">
<button
class="px-2 py-1 text-xs rounded transition-colors bg-surface-variant text-on-surface-variant hover:bg-surface"
@click="theme.cycle(['light', 'dark'])"
>
Toggle theme
</button>
</div>
</div>
</div>
<!-- Breakpoints info -->
<div class="p-4 bg-surface border border-divider rounded-lg">
<h3 class="text-sm font-semibold mb-3">useBreakpoints()</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-on-surface-variant">Current:</span>
<span class="font-mono">{{ breakpoints.name?.value ?? 'unknown' }}</span>
</div>
<div class="flex justify-between">
<span class="text-on-surface-variant">Width:</span>
<span class="font-mono">{{ breakpoints.width?.value ?? 0 }}px</span>
</div>
<div class="flex gap-1 mt-3">
<span
v-for="bp in ['xs', 'sm', 'md', 'lg', 'xl']"
:key="bp"
class="px-1.5 py-0.5 text-xs rounded"
:class="breakpoints.name?.value === bp
? 'bg-primary text-on-primary'
: 'bg-surface-variant text-on-surface-variant'"
>
{{ bp }}
</span>
</div>
</div>
</div>
</div>
<div class="p-3 bg-surface-variant rounded-lg">
<p class="text-xs text-on-surface-variant font-mono">
// plugins/index.ts<br>
app.use(createThemePlugin({ ... }))<br>
app.use(createBreakpointsPlugin({ ... }))
</p>
</div>
</div>
</template>
v0 plugins are designed to be order-independent. Each plugin gracefully handles missing dependencies by providing sensible fallbacks.
SSR Safety
v0 is designed for universal rendering. Use the provided constants and composables to guard browser-only code:
import { useHydration, useWindowEventListener } from '@vuetify/v0'
// SSR-safe event listener (no-op on server, auto-cleanup)
useWindowEventListener('resize', handler)
// Reactive hydration state for conditional rendering
const { isHydrated } = useHydration()
// In templates: v-if="isHydrated"The isHydrated shallowRef is false during SSR and becomes true after the root component mounts. This prevents hydration mismatches when rendering browser-dependent content.
v0 provides two ways to handle SSR safely:
IN_BROWSER constant
Static check, evaluated once at import time.
if (IN_BROWSER) {
// Safe to use window, document
}useHydration()
Reactive ref, changes after mount.
const { isHydrated } = useHydration()
// v-if="isHydrated"Browser-only content
<script setup lang="ts">
import { useHydration } from '@vuetify/v0'
import { IN_BROWSER } from '@vuetify/v0/constants'
import { shallowRef, toRef, watchEffect } from 'vue'
const { isHydrated } = useHydration()
// Browser-only state
const windowWidth = shallowRef(0)
const userAgent = shallowRef('')
// Safe to access window after hydration
watchEffect(() => {
if (!isHydrated.value) return
windowWidth.value = window.innerWidth
userAgent.value = navigator.userAgent.slice(0, 50) + '...'
})
// Static check example
const staticBrowserCheck = IN_BROWSER ? 'Client' : 'Server'
const hydrationStatus = toRef(() => isHydrated.value ? 'Hydrated' : 'SSR')
</script>
<template>
<div class="space-y-4">
<p class="text-sm text-on-surface-variant">
v0 provides two ways to handle SSR safely:
</p>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Static check -->
<div class="p-4 bg-surface border border-divider rounded-lg">
<h3 class="text-sm font-semibold mb-3">IN_BROWSER constant</h3>
<p class="text-xs text-on-surface-variant mb-3">
Static check, evaluated once at import time.
</p>
<div class="p-2 bg-surface-variant rounded font-mono text-sm">
{{ staticBrowserCheck }}
</div>
<pre class="mt-3 text-xs text-on-surface-variant">if (IN_BROWSER) {
// Safe to use window, document
}</pre>
</div>
<!-- Reactive check -->
<div class="p-4 bg-surface border border-divider rounded-lg">
<h3 class="text-sm font-semibold mb-3">useHydration()</h3>
<p class="text-xs text-on-surface-variant mb-3">
Reactive ref, changes after mount.
</p>
<div
class="p-2 rounded font-mono text-sm"
:class="isHydrated ? 'bg-success/20 text-success' : 'bg-warning/20 text-warning'"
>
{{ hydrationStatus }}
</div>
<pre class="mt-3 text-xs text-on-surface-variant">const { isHydrated } = useHydration()
// v-if="isHydrated"</pre>
</div>
</div>
<!-- Browser-only content -->
<div class="p-4 bg-surface border border-divider rounded-lg">
<h3 class="text-sm font-semibold mb-3">Browser-only content</h3>
<div v-if="isHydrated" class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-on-surface-variant">Window width:</span>
<span class="font-mono">{{ windowWidth }}px</span>
</div>
<div class="flex justify-between">
<span class="text-on-surface-variant">User agent:</span>
<span class="font-mono text-xs">{{ userAgent }}</span>
</div>
</div>
<div v-else class="text-sm text-on-surface-variant italic">
Loading browser data...
</div>
</div>
</div>
</template>
TypeScript Patterns
v0 uses generics extensively. When extending composables, provide your custom ticket and context types:
Extend v0's ticket and context types for custom properties:
<script setup lang="ts">
import type { SelectionContext, SelectionTicket } from '@vuetify/v0/composables'
import type { ComputedRef } from 'vue'
import { createSelection, useProxyRegistry } from '@vuetify/v0'
// Extend the base ticket with custom properties
interface FileTicket extends SelectionTicket<string> {
icon: string
size: number
}
// Extend the context with custom computed properties
interface FileContext extends SelectionContext<FileTicket> {
totalSize: ComputedRef<number>
}
// Icon paths (mdi icons)
const icons: Record<string, string> = {
'file-document': 'M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M15,18V16H6V18H15M18,14V12H6V14H18Z',
'code-json': 'M5,3H7V5H5V10A2,2 0 0,1 3,12A2,2 0 0,1 5,14V19H7V21H5C3.93,20.73 3,20.1 3,19V15A2,2 0 0,0 1,13H0V11H1A2,2 0 0,0 3,9V5A2,2 0 0,1 5,3M19,3A2,2 0 0,1 21,5V9A2,2 0 0,0 23,11H24V13H23A2,2 0 0,0 21,15V19A2,2 0 0,1 19,21H17V19H19V14A2,2 0 0,1 21,12A2,2 0 0,1 19,10V5H17V3H19M12,15A1,1 0 0,1 13,16A1,1 0 0,1 12,17A1,1 0 0,1 11,16A1,1 0 0,1 12,15M8,15A1,1 0 0,1 9,16A1,1 0 0,1 8,17A1,1 0 0,1 7,16A1,1 0 0,1 8,15M16,15A1,1 0 0,1 17,16A1,1 0 0,1 16,17A1,1 0 0,1 15,16A1,1 0 0,1 16,15Z',
'folder': 'M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z',
'folder-outline': 'M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z',
}
// Create typed selection with custom ticket
const files = createSelection<FileTicket, FileContext>({
multiple: true,
events: true,
})
// Register items with extended properties
files.onboard([
{ id: 'readme', value: 'README.md', icon: 'file-document', size: 2048 },
{ id: 'package', value: 'package.json', icon: 'code-json', size: 1024 },
{ id: 'src', value: 'src/', icon: 'folder', size: 4096 },
{ id: 'tests', value: 'tests/', icon: 'folder-outline', size: 8192 },
])
const proxy = useProxyRegistry(files)
// Access extended properties with full type safety
function formatSize (bytes: number): string {
if (bytes < 1024) return `${bytes} B`
return `${(bytes / 1024).toFixed(1)} KB`
}
</script>
<template>
<div class="space-y-4">
<p class="text-sm text-on-surface-variant">
Extend v0's ticket and context types for custom properties:
</p>
<div class="border border-divider rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-surface-variant text-xs font-mono text-on-surface-variant">
interface FileTicket extends SelectionTicket<string> { icon, size }
</div>
<div class="divide-y divide-divider">
<button
v-for="file in proxy.values"
:key="file.id"
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors"
:class="file.isSelected.value ? 'bg-primary/10' : 'hover:bg-surface-variant'"
@click="file.toggle"
>
<svg
:class="file.isSelected.value ? 'text-primary' : 'text-on-surface-variant'"
fill="currentColor"
height="18"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path :d="icons[file.icon]" />
</svg>
<span class="flex-1 text-sm" :class="file.isSelected.value ? 'text-primary font-medium' : ''">
{{ file.value }}
</span>
<span class="text-xs text-on-surface-variant font-mono">
{{ formatSize(file.size) }}
</span>
<span
class="size-4 rounded border flex items-center justify-center text-xs"
:class="file.isSelected.value
? 'bg-primary border-primary text-on-primary'
: 'border-divider'"
>
<svg
v-if="file.isSelected.value"
fill="currentColor"
height="12"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
</svg>
</span>
</button>
</div>
</div>
<div class="flex justify-between items-center p-3 bg-surface-variant rounded-lg text-sm">
<span class="text-on-surface-variant">
{{ files.selectedIds.size }} selected
</span>
<span class="font-mono text-xs">
{{ formatSize(Array.from(files.selectedItems.value).reduce((sum, f) => sum + f.size, 0)) }}
</span>
</div>
</div>
</template>
Vue’s shallowReactive↗ and computed↗ are used internally—understanding these helps when debugging reactivity issues.
Complete Example: @example/my-ui
To see everything come together, we’ve included a complete example library that demonstrates the patterns in this guide.
Example library built on v0 — see my-ui/ source
MyButton
MyTabs
Overview panel content. The tabs handle keyboard navigation automatically.
Features panel content. Arrow keys, Home, and End all work.
API panel content. ARIA attributes are managed by v0.
MyAccordion
pnpm add @example/my-ui @vuetify/v0<script setup lang="ts">
import { shallowRef } from 'vue'
import MyAccordion from './src/components/MyAccordion.vue'
import MyButton from './src/components/MyButton.vue'
import MyTabs from './src/components/MyTabs.vue'
const activeTab = shallowRef('overview')
const expandedItems = shallowRef<string[]>(['getting-started'])
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'features', label: 'Features' },
{ value: 'api', label: 'API' },
]
const accordionItems = [
{ value: 'getting-started', title: 'Getting Started' },
{ value: 'installation', title: 'Installation' },
{ value: 'configuration', title: 'Configuration' },
]
</script>
<template>
<div class="space-y-8">
<p class="text-sm text-on-surface-variant">
Example library built on v0 — see <code>my-ui/</code> source
</p>
<!-- Buttons -->
<section>
<h3 class="text-sm font-semibold mb-3">MyButton</h3>
<div class="flex flex-wrap gap-2">
<MyButton>Default</MyButton>
<MyButton variant="outlined">Outlined</MyButton>
<MyButton variant="text">Text</MyButton>
<MyButton color="neutral">Neutral</MyButton>
<MyButton size="sm">Small</MyButton>
<MyButton size="lg">Large</MyButton>
<MyButton disabled>Disabled</MyButton>
<MyButton as="a" href="#">Link</MyButton>
</div>
</section>
<!-- Tabs -->
<section>
<h3 class="text-sm font-semibold mb-3">MyTabs</h3>
<MyTabs v-model="activeTab" :items="tabs">
<template #overview>
<p>Overview panel content. The tabs handle keyboard navigation automatically.</p>
</template>
<template #features>
<p>Features panel content. Arrow keys, Home, and End all work.</p>
</template>
<template #api>
<p>API panel content. ARIA attributes are managed by v0.</p>
</template>
</MyTabs>
</section>
<!-- Accordion -->
<section>
<h3 class="text-sm font-semibold mb-3">MyAccordion</h3>
<MyAccordion v-model="expandedItems" :items="accordionItems" multiple>
<template #getting-started>
Install the package and register the plugin in your Vue app.
</template>
<template #installation>
<code>pnpm add @example/my-ui @vuetify/v0</code>
</template>
<template #configuration>
Pass theme options to MyUIPlugin() for custom colors.
</template>
</MyAccordion>
</section>
</div>
</template>
The example package includes:
my-ui/
├── package.json # Peer deps on @vuetify/v0 and vue
├── vite.config.ts # Library build config
├── src/
│ ├── index.ts # Public exports
│ ├── plugin.ts # Vue plugin with v0 setup
│ └── components/
│ ├── MyButton.vue # Atom wrapper (polymorphic)
│ ├── MyTabs.vue # createSingle + keyboard nav
│ └── MyAccordion.vue # ExpansionPanel wrapperView the full source in the examples directory↗.