Interactive

Accordion

A vertical stack of collapsible panels that spring their height open and closed.

A vertical stack of collapsible panels. MAccordion composes Nuxt UI's UAccordion — the state (reka-ui), the real <button> triggers and the panel regions are owned by Nuxt UI, while the open/close height runs on a motion-v spring layered over the panel. MAccordion forwards the full UAccordion API (items, v-model, type, collapsible, defaultValue, ui, …) and keeps a thin mapping for the old expandedValue / onValueChange props.

Panels stay mounted when closed by default (unmountOnHide defaults to false) so the closing spring is visible; the collapsed content is kept out of the a11y tree with inert instead of being removed.

The most common shape is a v-model bound to the currently open item's value:

const expandedValue = ref<string | string[] | null>(null)

const faqs = [
  {
    label: 'How do I install the module?',
    value: 'install',
    content: 'Add nuxt-motion-block to your modules array, then drop any M-prefixed component straight into your template.',
  },
  {
    label: 'Does the animation survive SSR?',
    value: 'ssr',
    content: 'Yes. A panel only mounts once it is expanded.',
  },
]
<template>
  <MAccordion
    v-model="expandedValue"
    :items="faqs"
  />
</template>

Or bring your own trigger by slotting the default (label) content:

Nothing expanded

<template>
  <div class="flex w-full max-w-lg flex-col gap-3">
    <MAccordion
      v-model="expandedValue"
      :items="faqs"
    >
      <template #default="{ item }">
        <span class="text-sm font-medium text-highlighted">{{ item.label }}</span>
      </template>
    </MAccordion>

    <p class="text-xs text-dimmed">
      {{ expandedValue ? `Expanded: ${expandedValue}` : 'Nothing expanded' }}
    </p>
  </div>
</template>

<script setup lang="ts">
const expandedValue = ref<string | string[] | null>(null)

const faqs = [
  {
    label: 'How do I install the module?',
    value: 'install',
    content: 'Add nuxt-motion-block to your modules array, then drop any M-prefixed component straight into your template.',
  },
  {
    label: 'Does the animation survive SSR?',
    value: 'ssr',
    content: 'Yes. Panels stay mounted when closed by default, so opening is a pure height spring and the content is preserved the whole time.',
  },
  {
    label: 'Can I change the open and close curve?',
    value: 'ui',
    content: 'The panel height runs on a motion-v spring (opening 500/40, closing 550/45). You can tune it or swap it for an easing through the accordion `ui` theme.',
  },
]
</script>

Panels are driven by a list of items. Each item maps to one MAccordionItem the old API rendered manually:

const items = [
  {
    label: 'The trigger label',
    content: 'The panel body (renders as text; almost any markdown-ish string)',
    value: '1', // optional; falls back to the item index
    icon: 'i-lucide-circle', // optional leading icon
    disabled: true, // optional — triggers become inert
  },
]

Pass defaultValue instead of v-model to leave the accordion uncontrolled, and type="multiple" to let several panels stay open at once.

Usage

Nothing expanded

<template>
  <div class="flex w-full max-w-lg flex-col gap-3">
    <MAccordion
      v-model="expandedValue"
      :items="faqs"
    >
      <template #default="{ item }">
        <span class="text-sm font-medium text-highlighted">{{ item.label }}</span>
      </template>
    </MAccordion>

    <p class="text-xs text-dimmed">
      {{ expandedValue ? `Expanded: ${expandedValue}` : 'Nothing expanded' }}
    </p>
  </div>
</template>

<script setup lang="ts">
const expandedValue = ref<string | string[] | null>(null)

const faqs = [
  {
    label: 'How do I install the module?',
    value: 'install',
    content: 'Add nuxt-motion-block to your modules array, then drop any M-prefixed component straight into your template.',
  },
  {
    label: 'Does the animation survive SSR?',
    value: 'ssr',
    content: 'Yes. Panels stay mounted when closed by default, so opening is a pure height spring and the content is preserved the whole time.',
  },
  {
    label: 'Can I change the open and close curve?',
    value: 'ui',
    content: 'The panel height runs on a motion-v spring (opening 500/40, closing 550/45). You can tune it or swap it for an easing through the accordion `ui` theme.',
  },
]
</script>

Multiple panels

Open several panels at once by binding an array with type="multiple" — each item's leading icon is rendered by Nuxt UI, and any external control can mutate the bound array:

1 / 3 open
Each trigger is a real button carrying aria-expanded, so keyboard and screen readers work without any extra wiring.

With type="multiple" every panel springs open independently and the bound array stays in sync.

<template>
  <div class="flex w-full max-w-lg flex-col gap-3">
    <div class="flex items-center gap-2">
      <UButton
        size="sm"
        variant="soft"
        @click="expandAll"
      >
        Expand all
      </UButton>
      <UButton
        size="sm"
        variant="soft"
        color="neutral"
        @click="open = []"
      >
        Collapse all
      </UButton>
      <span class="ml-auto text-xs text-dimmed">{{ open.length }} / {{ features.length }} open</span>
    </div>

    <MAccordion
      v-model="open"
      type="multiple"
      :items="features"
    />

    <p class="text-xs text-dimmed">
      With <code>type="multiple"</code> every panel springs open independently and the bound array stays in sync.
    </p>
  </div>
</template>

<script setup lang="ts">
const features: { label: string, value: string, icon: string, content: string }[] = [
  {
    label: 'Sortable panels',
    value: 'panels',
    icon: 'i-lucide-layers',
    content: 'Each trigger is a real button carrying aria-expanded, so keyboard and screen readers work without any extra wiring.',
  },
  {
    label: 'Spring height',
    value: 'spring',
    icon: 'i-lucide-waves',
    content: 'The panel height is measured with scrollHeight and driven 0 → px by a motion-v spring, then restored to auto once it lands.',
  },
  {
    label: 'Open several at once',
    value: 'multiple',
    icon: 'i-lucide-git-branch',
    content: 'Binding an array with type="multiple" lets panels open independently — drive it from buttons, routes, or search.',
  },
]

const open = ref<string[]>(['panels'])

function expandAll() {
  open.value = features.map(feature => feature.value)
}
</script>

Customize

Customize

Model Value
Default Value
Typesingle
Collapsible
Disabled
Unmount On Hide
Trailing Icon
Value Key
Label Key
Expanded Value
On Value Change

Props

::component-prop{name="valueKey" / "labelKey" type="string" default="'value' / 'label'"} Dot-path keys for reading each item's value and label. ::

Prop Type Default Description
itemsAccordionItem[]-The panels. One MAccordionItem per panel, read from label, content, value, icon, disabled and so on. See the Nuxt UI Accordion docs for the full item shape, including slot to pick a per-item trigger slot and ui for per-item overrides.
modelValuestring | string[]undefinedThe controlled open value(s). Bindable with v-model. In single mode the collapsed value is ''.
defaultValuestring | string[]undefinedThe initial open value(s) when uncontrolled.
type'single' | 'multiple''single'Whether one or several panels can be open at a time.
collapsiblebooleantrueWhen type="single", whether clicking an open trigger close it again.
disabledbooleanfalsePrevents interaction with the accordion and all its items.
unmountOnHidebooleanfalseWhen false (default) the panel stays mounted while closed so its height can spring back down; the collapsed content is inert. true unmounts a closed panel entirely — the classic "no exit motion" trade.
trailingIconIconifyIcon-The icon on the right of every trigger. Defaults to Nuxt UI's chevron index.
expandedValuestring | number | nullundefinedDeprecated alias for modelValue, kept for parity with the pre-Nuxt-UI API. null maps to the collapsed ''. Prefer v-model.
onValueChange(value: string | number | null | string[]) => voidundefinedDeprecated. Called with the newly open value, or null when the open panel is toggled shut. Prefer @update:model-value / v-model.
classstring-Merged onto the accordion root.
uiobject-Nuxt UI ::ui theme overrides — the same shape as UAccordion. The available slots are root, item, header, trigger, content, body, leadingIcon, trailingIcon and label.

Slots

All slots are forwarded to the underlying UAccordion.

::component-slot-row{name="leading" / "trailing" type="{ item, index, open, ui }"} The leading/trailing icon slots of the trigger. ::

Slot Props Description
default{ item, index, open }The trigger content for every item. Slot in a label, chevron, or button — the surrounding element is the trigger, so it stays a real button.
content{ item, index, open, ui }The open panel body.
body{ item, index, open, ui }Same as content, but the inner body wrapper — useful with unmountOnHide="false" to keep padding stable.

Accessibility

Semantics come from reka-ui's accordion wrapped by Nuxt UI: every trigger is a native <button> carrying aria-expanded and aria-controls, and Enter/Space toggle for free. With the default unmountOnHide: false a closed panel stays mounted but is removed from the accessibility tree and tab order via inert and hidden from find-in-page via hidden="until-found". With unmountOnHide: true the panel unmounts instead.

Performance

  • The panel height is animated imperatively with motion-v springs: scrollHeight is measured and the spring runs 0 → px to open and px → 0 to close, then the height is restored to auto once the opening spring lands so the panel stays fluid.
  • Only panels whose open state actually changed are animated — a second accordion sharing the same v-model does not re-run the opening spring on an already-open panel — and a rapid toggle lets only the latest animation write the end state.
  • The motion is height-based (layout) with overflow-hidden during the transition; Nuxt UI's own CSS keyframe animation on the content wrapper is cancelled so only the spring runs.
  • With unmountOnHide: true (legacy) a closed panel renders nothing.
Copyright © 2026