Button
Headless button with loading state, toggle group support, 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.
Anatomy
<script setup lang="ts">
import { Button } from '@vuetify/v0'
</script>
<template>
<Button.Root>
<Button.Icon />
<Button.Content />
<Button.Loading />
</Button.Root>
<Button.Group>
<Button.Root>
<Button.HiddenInput />
</Button.Root>
</Button.Group>
</template>Recipes
Loading with Grace Period
The loading state can delay its visual indicator through the grace prop — a millisecond window that prevents flicker for fast operations: if the async work completes within it, the loading indicator never appears. The default is 0, so the indicator shows immediately unless you opt in.
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.
Button.Loading and Button.Content conditionally render based on the loading state. Only one is visible at a time — Content by default, Loading once any configured grace window elapses (immediately with the default grace: 0).
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
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.
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>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 |
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.
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-disabledis true when the button is disabled or passive.passivestays focusable.disabledalso sets the nativedisabledattributearia-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>FAQ
Both block clicks, but disabled applies the native disabled attribute and drops the button from tab order, while passive uses aria-disabled="true" so the button stays focusable and screen readers announce it as disabled. Reach for passive when a control is only temporarily unavailable.
Set the grace prop (milliseconds, default 0) to delay the indicator. If the async work finishes within that window the indicator never shows, preventing flicker on fast operations.
Put a Button.HiddenInput inside each Button.Root. It renders a visually hidden checkbox that reflects the button’s selected state, so the group’s value posts with the form.
Hold a template ref on Button.Root typed as AtomExpose — ref.value?.element is the host; call .focus() on it. On Button.Group, type the ref as ButtonGroupExpose and call ref.value?.focus() — that focuses the selected item, or the first if none is selected. Pass DOM FocusOptions (preventScroll, focusVisible); focusVisible is pass-through only, not emulated. Renderless roots expose element: null — there is no host to focus.
<script setup lang="ts">
import { Button } from '@vuetify/v0'
import type { AtomExpose, ButtonGroupExpose } from '@vuetify/v0'
import { useTemplateRef } from 'vue'
const root = useTemplateRef<AtomExpose>('root')
const group = useTemplateRef<ButtonGroupExpose>('group')
</script>
<template>
<Button.Root ref="root">Click</Button.Root>
<Button.Group ref="group">
<Button.Root value="a">A</Button.Root>
</Button.Group>
</template>