Skip to main content
You are viewing Pre-Alpha documentation.
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

A headless button component with four interaction states, loading grace period, toggle groups, and icon accessibility.


IntermediateMar 23, 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.

Link button
<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

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

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

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.

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:

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.

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

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>

API Reference

The following API details are for all variations of the Button component.

Button.Root

Props

disabled

boolean

Disables the button — fully non-interactive, removed from tab order

Default: false

readonly

boolean

Non-clickable but looks normal, remains focusable/hoverable

passive

boolean

Non-clickable, looks disabled via [data-passive], remains focusable/hoverable

Default: false

loading

boolean

Triggers loading state with grace period before visual indicator

Default: false

grace

number

Duration in ms before loading UI appears (0 to show immediately)

Default: 0

value

V

Value for use inside Button.Group

namespace

string

Namespace for context provision to children

Default: "v0:button:root"

groupNamespace

string

Namespace for connecting to parent Button.Group

Default: "v0:button:group"

name

string

Form field name — auto-renders HiddenInput when set

form

string

Associate with form by ID

ariaLabel

string

Accessible label for the button

Slots

default

ButtonRootSlotProps

Button.Content

Props

id

ID

Unique identifier for ticket registration

Default: useId()

namespace

string

Namespace for context injection from parent Button.Root

Default: "v0:button:root"

Slots

default

ButtonContentSlotProps

Button.Group

Props

namespace

string

Namespace for dependency injection

Default: "v0:button:group"

disabled

boolean

Disables the entire button group

Default: false

multiple

boolean

Single (default) or multi-select

Default: false

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

label

string

Accessible name for the group

ariaLabelledby

string

ID of element that labels this group

ariaDescribedby

string

ID of element that describes this group

modelValue

T | T[]

Events

update:model-value

[value: T | T[]]

Slots

default

ButtonGroupSlotProps

Button.HiddenInput

Props

value

string

Submitted value (defaults to 'on')

name

string

Form field name

namespace

string

Namespace for context injection from parent Button.Root

Default: "v0:button:root"

form

string

Associate with form by ID

Button.Icon

Props

namespace

string

Namespace for context injection from parent Button.Root

Default: "v0:button:root"

Slots

default

ButtonIconSlotProps

Button.Loading

Props

id

ID

Unique identifier for ticket registration

Default: useId()

namespace

string

Namespace for context injection from parent Button.Root

Default: "v0:button:root"

Slots

default

ButtonLoadingSlotProps
Was this page helpful?

© 2016-1970 Vuetify, LLC
Ctrl+/