Button
A headless button component with four interaction states, loading grace period, toggle groups, and icon accessibility.
Usage
The Button component renders as a native <button> by default (or an anchor, router-link, etc. via the as prop). It provides four distinct interaction states for controlling click behavior and visual feedback.
<script setup lang="ts">
import { Button } from '@vuetify/v0'
function onClick () {
alert('Clicked!')
}
</script>
<template>
<div class="flex gap-3">
<Button.Root
class="px-4 py-2 bg-primary text-on-primary rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
@click="onClick"
>
Click me
</Button.Root>
<Button.Root
as="a"
class="px-4 py-2 bg-secondary text-on-secondary rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
href="https://vuetifyjs.com"
target="_blank"
>
Link button
</Button.Root>
</div>
</template>
Anatomy
<script setup lang="ts">
import { Button } from '@vuetify/v0'
import { mdiSend } from '@mdi/js'
</script>
<template>
<Button.Root>
<Button.Icon>
<svg viewBox="0 0 24 24"><path :d="mdiSend" /></svg>
</Button.Icon>
<Button.Content>Submit</Button.Content>
<Button.Loading>...</Button.Loading>
</Button.Root>
<Button.Group>
<Button.Root>
A
<Button.HiddenInput name="choice" />
</Button.Root>
<Button.Root>
B
<Button.HiddenInput name="choice" />
</Button.Root>
</Button.Group>
</template>Interaction States
Button supports four states that block click events. Each state has a distinct semantic meaning:
| State | Click blocked | Focusable | Hoverable | Tab order | Use case |
|---|---|---|---|---|---|
| disabled | Yes | No | No | Removed | Button is not applicable |
| readonly | Yes | Yes | Yes | Kept | Display-only, no action needed |
| passive | Yes | Yes | Yes | Kept | Temporarily unavailable |
| loading | Yes | Yes | Yes | Kept | Waiting for async operation |
<script setup lang="ts">
import { Button, useTimer } from '@vuetify/v0'
import { shallowRef } from 'vue'
const loading = shallowRef(false)
const loaded = shallowRef(false)
const reset = useTimer(
() => {
loaded.value = false
},
{ duration: 2000 },
)
function onLoad () {
if (loaded.value || loading.value) return
loading.value = true
const delay = Math.random() > 0.5 ? 100 : 3000
setTimeout(() => {
loading.value = false
loaded.value = true
reset.start()
}, delay)
}
</script>
<template>
<div class="flex flex-wrap gap-4 items-center justify-center">
<div class="flex flex-col items-center gap-1">
<Button.Root
class="px-4 py-2 rounded-md text-sm font-medium bg-primary text-on-primary opacity-50 cursor-not-allowed"
disabled
>
Disabled
</Button.Root>
<span class="text-xs text-on-surface-variant">native disabled</span>
</div>
<div class="flex flex-col items-center gap-1">
<Button.Root
class="px-4 py-2 rounded-md text-sm font-medium bg-primary text-on-primary data-[readonly]:ring-2 data-[readonly]:ring-primary/50"
readonly
>
Readonly
</Button.Root>
<span class="text-xs text-on-surface-variant">focusable, no click</span>
</div>
<div class="flex flex-col items-center gap-1">
<Button.Root
class="px-4 py-2 rounded-md text-sm font-medium bg-primary text-on-primary data-[passive]:opacity-50 data-[passive]:cursor-not-allowed"
passive
title="This action is temporarily unavailable"
>
Passive
</Button.Root>
<span class="text-xs text-on-surface-variant">aria-disabled</span>
</div>
<div class="flex flex-col items-center gap-1">
<Button.Root
class="px-4 py-2 rounded-md text-sm font-medium bg-primary text-on-primary min-w-24 relative inline-flex items-center justify-center data-[loading]:cursor-default"
:loading
@click="onLoad"
>
<Button.Loading v-slot="{ isSelected }">
<span class="absolute inset-0 flex items-center justify-center transition-opacity" :class="isSelected ? 'opacity-100' : 'opacity-0'">
<span class="inline-block w-4 h-4 border-2 border-on-primary border-t-transparent rounded-full animate-spin" />
</span>
</Button.Loading>
<Button.Content v-slot="{ isSelected }">
<span :class="isSelected ? 'visible' : 'invisible'">{{ loaded ? 'Loaded' : 'Load' }}</span>
</Button.Content>
</Button.Root>
<span class="text-xs text-on-surface-variant">1s grace period</span>
</div>
</div>
</template>
Data Attributes
Each state sets a corresponding data-* attribute on the element for CSS styling:
| Attribute | When set |
|---|---|
data-disabled | disabled prop is true |
data-readonly | readonly prop is true |
data-passive | passive prop is true |
data-loading | Loading grace period has elapsed |
data-selected | Button is selected in a group |
disabled uses native disabled attribute and removes the button from tab order. passive uses aria-disabled="true" instead — the button stays focusable and screen readers announce it as disabled.
Recipes
Loading with Grace Period
The loading state has a built-in 1-second grace period before showing loading UI. This prevents flicker for fast operations — if the async work completes within 1 second, the loading indicator never appears.
Use Button.Loading and Button.Content to swap between loading and default content:
Click the button — the loading indicator appears after a 1-second grace period.
<script setup lang="ts">
import { Button } from '@vuetify/v0'
import { shallowRef } from 'vue'
const loading = shallowRef(false)
function onSave () {
loading.value = true
setTimeout(() => {
loading.value = false
}, 3000)
}
</script>
<template>
<Button.Root
class="relative px-4 py-2 bg-primary text-on-primary rounded-md text-sm font-medium hover:opacity-90 transition-opacity data-[loading]:cursor-wait"
:loading
@click="onSave"
>
<Button.Loading v-slot="{ isSelected }">
<span class="absolute inset-0 flex items-center justify-center transition-opacity" :class="isSelected ? 'opacity-100' : 'opacity-0'">
Saving...
</span>
</Button.Loading>
<Button.Content v-slot="{ isSelected }">
<span class="transition-opacity" :class="isSelected ? 'opacity-100' : 'opacity-0'">
Save
</span>
</Button.Content>
</Button.Root>
<p class="mt-3 text-sm text-on-surface-variant">
Click the button — the loading indicator appears after a 1-second grace period.
</p>
</template>
Button.Loading and Button.Content use an internal selection context. Only one is “selected” at a time — Content by default, Loading after the grace period elapses. Use the isSelected slot prop to drive visibility.
Toggle Groups
Wrap buttons in Button.Group for toggle behavior with v-model support. Each Button.Root needs a value prop to participate in selection.
Selected: none
<script setup lang="ts">
import { Button } from '@vuetify/v0'
import { ref } from 'vue'
const alignment = ref<string>()
</script>
<template>
<Button.Group
v-model="alignment"
class="inline-flex rounded-md border border-divider overflow-hidden"
label="Text alignment"
>
<Button.Root
v-for="option in ['left', 'center', 'right']"
:key="option"
class="px-3 py-2 text-sm font-medium bg-surface hover:bg-surface-tint data-[selected]:bg-primary data-[selected]:text-on-primary transition-colors border-r border-divider last:border-r-0"
:value="option"
>
{{ option[0].toUpperCase() + option.slice(1) }}
</Button.Root>
</Button.Group>
<p class="mt-3 text-sm text-on-surface-variant">
Selected: {{ alignment ?? 'none' }}
</p>
</template>
Button.Group supports multiple for multi-select and mandatory to prevent deselecting the last item:
<template>
<!-- Multi-select -->
<Button.Group v-model="formatting" multiple>
<Button.Root value="bold">B</Button.Root>
<Button.Root value="italic">I</Button.Root>
<Button.Root value="underline">U</Button.Root>
</Button.Group>
<!-- Mandatory single-select -->
<Button.Group v-model="view" mandatory>
<Button.Root value="grid">Grid</Button.Root>
<Button.Root value="list">List</Button.Root>
</Button.Group>
</template>Icon Buttons
Use Button.Icon to wrap icon content. It sets aria-hidden="true" on itself and detects icon-only buttons — warning in dev when aria-label is missing on Root.
<script setup lang="ts">
import { Button } from '@vuetify/v0'
</script>
<template>
<div class="flex gap-3">
<Button.Root
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-on-primary rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
>
<Button.Icon>
<svg
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
</svg>
</Button.Icon>
Add item
</Button.Root>
<Button.Root
aria-label="Close"
class="inline-flex items-center justify-center size-10 rounded-full bg-surface-variant text-on-surface-variant hover:opacity-80 transition-opacity"
>
<Button.Icon>
<svg
class="size-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</Button.Icon>
</Button.Root>
</div>
</template>
Form Submission
Use Button.HiddenInput inside a group to submit toggle state with forms. It renders a visually hidden checkbox that reflects the button’s selected state.
<script setup lang="ts">
import { Button, Form } from '@vuetify/v0'
import { shallowRef } from 'vue'
const answer = shallowRef<string>()
function onSubmit () {
console.log('Answer:', answer.value)
}
</script>
<template>
<Form @submit="onSubmit">
<Button.Group v-model="answer">
<Button.Root value="yes">
Yes
<Button.HiddenInput name="answer" value="yes" />
</Button.Root>
<Button.Root value="no">
No
<Button.HiddenInput name="answer" value="no" />
</Button.Root>
</Button.Group>
<button type="submit">Submit</button>
</Form>
</template>Accessibility
Button.Root handles ARIA attributes automatically:
role="button"for proper semanticstype="button"when rendered as a<button>(prevents implicit form submission)aria-pressedreflects selection state when inside a grouparia-disabled="true"for passive state (not native disabled)aria-labelfrom theariaLabelproptabindex="0"for keyboard focus (-1when disabled)Native
disabledattribute when disabled (removes from tab order)
For custom implementations, use renderless mode and bind the attrs slot prop:
<template>
<Button.Root v-slot="{ attrs }" renderless>
<div v-bind="attrs">
<!-- Custom button visual -->
</div>
</Button.Root>
</template>Button.Root
Props
passive
booleanNon-clickable, looks disabled via [data-passive], remains focusable/hoverable
Default: false
Slots
default
ButtonRootSlotPropsButton.Content
Props
Slots
default
ButtonContentSlotPropsButton.Group
Props
mandatory
boolean | "force"Controls mandatory selection behavior: - false (default): No mandatory enforcement - true: Prevents deselecting the last selected item - `force`: Automatically selects the first non-disabled item on registration
Default: false
modelValue
T | T[]Events
update:model-value
[value: T | T[]]Slots
default
ButtonGroupSlotPropsButton.HiddenInput
Props
Button.Icon
Props
Slots
default
ButtonIconSlotPropsButton.Loading
Props
Slots
default
ButtonLoadingSlotProps