Skip to main content
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
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

Button

Headless button with loading state, toggle group support, and icon accessibility.

Edit this page
Report a Bug
Open issues
View on GitHub
Copy Markdown

PreviewRenders elementIntermediateAug 14, 2026

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.

Theme
Mode
Palettes
Accessibility
<script setup lang="ts">
  import { Button } from '@vuetify/v0'
</script>

<template>
  <div class="text-center">
    <Button.Root
      class="px-4 py-2 bg-primary text-on-primary rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
    >
      Click me
    </Button.Root>
  </div>
</template>

Anatomy

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

Theme
Mode
Palettes
Accessibility
<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"
    :grace="1000"
    :loading
    @click="onSave"
  >
    <Button.Loading>
      <span class="absolute inset-0 flex items-center justify-center">
        Saving...
      </span>
    </Button.Loading>

    <Button.Content>
      Save
    </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 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

Theme
Mode
Palettes
Accessibility
<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]:hover:bg-primary/90 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:

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

Theme
Mode
Palettes
Accessibility
<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.

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

StateClick blockedFocusableHoverableTab orderUse case
disabledYesNoNoRemovedButton is not applicable
readonlyYesYesYesKeptDisplay-only, no action needed
passiveYesYesYesKeptTemporarily unavailable
loadingYesYesYesKeptWaiting for async operation
native disabled
focusable, no click
aria-disabled
1s grace period
Theme
Mode
Palettes
Accessibility
<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 data-[passive]:cursor-default"
        :grace="1000"
        :loading
        :passive="loaded"
        @click="onLoad"
      >
        <Button.Loading>
          <span class="absolute inset-0 flex items-center justify-center">
            <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>
          {{ loaded ? 'Loaded' : 'Load' }}
        </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:

AttributeWhen set
data-disableddisabled prop is true
data-readonlyreadonly prop is true
data-passivepassive prop is true
data-loadingLoading grace period has elapsed
data-selectedButton is selected in a group
Tip

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 semantics

  • type="button" when rendered as a <button> (prevents implicit form submission)

  • aria-pressed reflects selection state when inside a group

  • aria-disabled="true" for passive state (not native disabled)

  • aria-label from the ariaLabel prop

  • tabindex="0" for keyboard focus (-1 when disabled)

  • Native disabled attribute when disabled (removes from tab order)

For custom implementations, use renderless mode and bind the attrs slot prop:

vue
<template>
  <Button.Root v-slot="{ attrs }" renderless>
    <div v-bind="attrs">
      <!-- Custom button visual -->
    </div>
  </Button.Root>
</template>

FAQ

Discord
Need help? Join our community for support and discussions ↗
Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/