Skip to main content
You are viewing Pre-Alpha documentation.
Vuetify0 Logo
Theme
Mode
Accessibility
Vuetify
Vuetify One

Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

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.


IntermediateJan 27, 2026

Getting Started

New Project

Use vuetify0 create to scaffold a new project with v0 pre-configured:

bash
pnpm create vuetify0

Existing Project

Add v0 to an existing Vue project:

bash
pnpm add @vuetify/v0

v0 uses subpath exports↗ for tree-shaking:

ts
// 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:

PatternWhen to Use
Pattern A: Behavior-FocusedNeed complex state (selection, navigation, validation) with full rendering control
Pattern B: Component WrappersBuilding styled components on top of v0’s headless primitives
Tip

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.

ts
// Direct instance (component-local)
const tabs = createSingle({ mandatory: true })

// Context trinity (for dependency injection)
const [useTabs, provideTabs, defaultTabs] = createSingleContext()
Tip

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.

ts
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 item

Template Iteration

Registry internals aren’t directly exposed for template iteration. useProxyRegistry wraps a registry and provides reactive arrays that update when items change:

vue
<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>
Tip

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.

<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.

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:

0 of 3 expanded
<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>
Ask AI
How does this compare to v0's built-in Tabs component?

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:

MyButton.vue

Link Button

Variants

Sizes

Colors

<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>
Tip

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:

Headless components, composables, and utilities for building design systems. Full TypeScript support with generic constraints.

<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:

Selected: notifications, updates
<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()

Current:light
Dark mode:false

useBreakpoints()

Current:xs
Width:0px
xssmmdlgxl

// 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>
Tip

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:

ts
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.

Server
if (IN_BROWSER) {
  // Safe to use window, document
}

useHydration()

Reactive ref, changes after mount.

SSR
const { isHydrated } = useHydration()
// v-if="isHydrated"

Browser-only content

Loading browser data...
<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:

interface FileTicket extends SelectionTicket<string> { icon, size }
0 selected 0 B
<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&lt;string&gt; { 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

Link

MyTabs

Overview panel content. The tabs handle keyboard navigation automatically.

MyAccordion

Install the package and register the plugin in your Vue app.

<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 wrapper

View the full source in the examples directory↗.

Was this page helpful?

© 2016-1970 Vuetify, LLC
Ctrl+/