Accordion
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:
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
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | AccordionItem[] | - | 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. |
modelValue | string | string[] | undefined | The controlled open value(s). Bindable with v-model. In single mode the
collapsed value is ''. |
defaultValue | string | string[] | undefined | The initial open value(s) when uncontrolled. |
type | 'single' | 'multiple' | 'single' | Whether one or several panels can be open at a time. |
collapsible | boolean | true | When type="single", whether clicking an open trigger close it again. |
disabled | boolean | false | Prevents interaction with the accordion and all its items. |
unmountOnHide | boolean | false | When 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. |
trailingIcon | IconifyIcon | - | The icon on the right of every trigger. Defaults to Nuxt UI's chevron index. |
expandedValue | string | number | null | undefined | Deprecated 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[]) => void | undefined | Deprecated. Called with the newly open value, or null when the open panel
is toggled shut. Prefer @update:model-value / v-model. |
class | string | - | Merged onto the accordion root. |
ui | object | - | 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.
| 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:
scrollHeightis measured and the spring runs0 → pxto open andpx → 0to close, then the height is restored toautoonce 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-modeldoes 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-hiddenduring 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.