GrCollapse
Collapses and expands additional content on demand.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- there is a lot of content and not all of it is needed at once — questions and answers, settings, the details of a record;
- one section is open at a time —
accordioncloses the previous one; - the sections are needed by a blind user —
headingLevelsets a heading of the right level, and walking by headings works; - opening has to be intercepted —
beforeChangepostpones the expansion until a load or a confirmation.
When to take something else
| Need | Take |
|---|---|
| The sections are switched rather than expanded | GrTabs |
| The sections of a form follow one another | GrFormSection |
| The data is nested | GrTree |
| The content opens on top of the page | GrDialog / GrDrawer |
The heading level
The heading of a section renders as an h3 tag, and headingLevel fits it to the
structure of the page: inside an <h4> section an accordion has to start with h5,
otherwise navigation by headings gets a gap in levels. The button stays inside the
heading — the APG requires that for an accordion.
The surface
borderless removes the GrCard wrapper: an accordion inside a card, a sidebar or
a filter panel would otherwise get a second border and a second shadow. divided
controls the separators between sections.
<GrCollapse v-model="open" borderless :heading-level="5" expand-icon-position="start">
<GrCollapseItem name="filters" title="Filters">
<template #extra>
<GrBadge size="sm">3</GrBadge>
</template>
…
</GrCollapseItem>
</GrCollapse>
The #icon slot replaces the chevron, expandIconPosition moves it before the
heading. The #extra slot (a counter, a badge, a button) renders beside the
trigger rather than inside it: a <button> inside a <button> is invalid markup,
and axe catches it as nested-interactive.
A guard on switching
beforeChange(name, expanding) cancels the switch by returning false. The second
argument is where the section is heading, so that “save the changes?” is asked only
on collapsing. Until the guard has answered, a repeated click on the same heading is
ignored: otherwise two confirmations in a row would return the state to where it
started.
async function beforeChange(name: GrCollapseValue, expanding: boolean): Promise<boolean> {
if (expanding)
return true
return confirmDiscardChanges(name)
}The keyboard and nesting
The ↑/↓ arrows (in a ring) and Home/End walk only the headings of their
own accordion: a nested GrCollapse inside an expanded panel does not enter the
walk.
A collapsed panel is marked inert — neither Tab nor a screen reader enters it,
and at the same time (unlike hidden) the expansion animation is preserved.
The empty state
<GrCollapse>
<GrCollapseItem v-for="item in filtered" :key="item.name" v-bind="item" />
</GrCollapse>
The filter found nothing — the accordion will show text instead of an empty frame itself: a frame with no content reads as a breakage rather than as “nothing yet”.
Emptiness is judged by the content of the slot, not by the length of your data, so
both a v-for over an empty array and a v-if that rendered nothing work. The
comments a v-if leaves behind and the line breaks from the template do not count
as content — otherwise the placeholder would never appear.
The text comes from the locale (gr.collapse.empty). It can be overridden in two
ways, from the simple to the general:
<GrCollapse empty-text="No sections" />
<GrCollapse>
<template #empty>
<GrEmptyState title="Nothing found" description="Loosen the filter" />
</template>
</GrCollapse>
:empty="false" suppresses the automatic detection — that is needed when the
sections arrive asynchronously and the placeholder must not flash in the first
frame. :empty="true" shows it forcibly.
Playground 8
Loading…
<GrCollapse />Install
npm i @feugene/granularityImport
import { GrCollapse } from '@feugene/granularity/components/GrCollapse'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValue | GrCollapseValue | GrCollapseValue[] | undefined | undefined | — |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the sections. Unset — it comes from `GrConfigProvider`, otherwise `md`. |
headingLevel | 2 | 3 | 4 | 5 | 6 | undefined | undefined | The level of the section headings (`h2`…`h6`) — to fit the structure of the page. |
accordion | boolean | undefined | false | — |
divided | boolean | undefined | undefined | — |
borderless | boolean | undefined | undefined | — |
expandIconPosition | "end" | "start" | undefined | undefined | The side the chevron is on relative to the heading. |
beforeChange | GrCollapseBeforeChange | undefined | undefined | — |
empty | boolean | undefined | undefined | Whether it is empty. Unset — it is judged by the content: an accordion with no sections shows a placeholder instead of an empty frame. `false` suppresses the automatic detection — for instance when the sections arrive asynchronously and the text must not flash. |
emptyText | string | undefined | undefined | The text of the empty state. The `#empty` slot is stronger. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The sections of the accordion (`GrCollapseItem`). |
empty | any | The content of the empty state instead of the default text. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: GrCollapseModelValue] | — |
change | [value: GrCollapseModelValue] | — |
Examples 6
Empty accordion speaks for itself
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrCollapse, GrCollapseItem, GrSwitch } from '@feugene/granularity'
const sections = [
{ name: 'billing', title: 'Billing', body: 'Invoices, payment method and tax details.' },
{ name: 'members', title: 'Members', body: 'Roles, invitations and seat limits.' },
]
const opened = ref<string[]>(['billing'])
const showSections = ref(true)
// Пустоту считает сам аккордеон: фильтр, не нашедший ничего, отдаёт пустой
// `v-for` — заглушка появляется без единой строчки на стороне экрана.
const visible = computed(() => (showSections.value ? sections : []))
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="showSections">
Show sections
</GrSwitch>
<GrCollapse v-model="opened">
<GrCollapseItem
v-for="section in visible"
:key="section.name"
:name="section.name"
:title="section.title"
>
{{ section.body }}
</GrCollapseItem>
</GrCollapse>
<GrCollapse borderless>
<template #empty>
<span class="text-[var(--gr-muted-fg)]">Own markup instead of the default text — the `empty` slot.</span>
</template>
</GrCollapse>
</div>
</template>Accordion with controlled active item
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const active = ref<string | number | undefined>('profile')
const activeLabel = computed(() => {
if (active.value === 'profile')
return 'Profile setup'
if (active.value === 'notifications')
return 'Notifications'
if (active.value === 'security')
return 'Security review'
return 'Collapsed'
})
</script>
<template>
<div class="grid gap-3">
<div class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
<span>Open panel:</span>
<GrBadge tone="neutral">{{ activeLabel }}</GrBadge>
</div>
<GrCollapse v-model="active" accordion>
<GrCollapseItem name="profile" title="Profile setup">
Keep onboarding steps in a single accordion so only one block stays expanded at a time.
</GrCollapseItem>
<GrCollapseItem name="notifications" title="Notifications">
Group less-frequent preferences into a secondary panel without overwhelming the main settings form.
</GrCollapseItem>
<GrCollapseItem name="security" title="Security review">
Reserve the last section for sensitive actions or audit details.
</GrCollapseItem>
</GrCollapse>
</div>
</template>Multi-expand sections with custom title slot
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const expanded = ref<Array<string | number>>(['summary', 'alerts'])
</script>
<template>
<div class="grid gap-3">
<div class="text-sm text-[var(--gr-muted-fg)]">
Multi-expand mode works well for dense dashboards where several sections should stay visible together.
</div>
<GrCollapse v-model="expanded" :divided="false">
<GrCollapseItem name="summary">
<template #title>
<div class="flex items-center gap-2 text-sm font-600">
Executive summary
<GrBadge size="sm" tone="success">Ready</GrBadge>
</div>
</template>
Key financial highlights, ownership notes and recent approvals can stay open side by side.
</GrCollapseItem>
<GrCollapseItem name="alerts">
<template #title>
<div class="flex items-center gap-2 text-sm font-600">
Risk alerts
<GrBadge size="sm" tone="warning">2 active</GrBadge>
</div>
</template>
Use a custom title slot when you need counters, badges or richer inline status markers.
</GrCollapseItem>
<GrCollapseItem name="history" title="Change history">
Keep audit notes collapsed by default until the operator explicitly opens them.
</GrCollapseItem>
</GrCollapse>
</div>
</template>Parent disabled mode and item-level guard
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const disabled = ref(false)
const expanded = ref<Array<string | number>>(['active'])
</script>
<template>
<div class="grid gap-3">
<GrButton class="justify-self-start" variant="outline" @click="disabled = !disabled">
{{ disabled ? 'Unlock' : 'Lock' }} all sections
</GrButton>
<GrCollapse v-model="expanded" :disabled="disabled">
<GrCollapseItem name="active" title="Available section">
Switch the whole collapse to a read-only state during background sync or permission checks.
</GrCollapseItem>
<GrCollapseItem name="blocked" title="Individually disabled item" disabled>
Some items can remain unavailable even when the rest of the group is interactive.
</GrCollapseItem>
<GrCollapseItem name="notes" title="Operational notes">
Disabled styling is inherited from the parent and still preserves the overall layout.
</GrCollapseItem>
</GrCollapse>
</div>
</template>Borderless accordion inside a card
Report settings
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrCard, GrCollapse, GrCollapseItem } from '@feugene/granularity'
const expanded = ref<Array<string | number>>(['filters'])
</script>
<template>
<!-- Аккордеон уже внутри карточки: borderless снимает вторую рамку и вторую тень. -->
<GrCard padding="md" body-class="grid gap-3">
<template #header>
<div class="flex items-center justify-between gap-3">
<h3 class="m-0 text-base font-600 text-[var(--gr-fg)]">
Report settings
</h3>
<GrButton size="xs" variant="ghost">
Reset all
</GrButton>
</div>
</template>
<GrCollapse
v-model="expanded"
borderless
size="sm"
:heading-level="4"
expand-icon-position="start"
:divided="false"
>
<GrCollapseItem name="filters">
<template #title>
<span class="flex items-center gap-2 font-600">
Filters
<GrBadge size="xs" tone="primary">3</GrBadge>
</span>
</template>
Статус секции живёт в самом заголовке: подсветка строки накрывает его целиком, и правый край
держат отступы карточки, а не отдельная колонка.
</GrCollapseItem>
<GrCollapseItem name="columns">
<template #title>
<span class="flex items-center gap-2 font-600">
Columns
<GrBadge size="xs" tone="neutral">12 of 18</GrBadge>
</span>
</template>
Без рамки и разделителей строку структурирует только hover — поэтому он обязан доходить до
правого края, а не обрываться на середине.
</GrCollapseItem>
<GrCollapseItem name="schedule" title="Delivery schedule">
Шеврон слева читается как дерево в сайдбаре, справа — как классический аккордеон.
</GrCollapseItem>
</GrCollapse>
</GrCard>
</template>Async guard before collapsing
<script setup lang="ts">
import { ref } from 'vue'
import { GrCollapse, GrCollapseItem, GrFormField, GrInput, GrSwitch } from '@feugene/granularity'
const expanded = ref<Array<string | number>>(['draft'])
const draft = ref('Quarterly report')
const dirty = ref(true)
const lastDecision = ref('—')
// Guard может быть async: пока он не ответил, повторный клик по заголовку
// игнорируется, поэтому диалог не откроется дважды.
async function beforeChange(name: string | number, expanding: boolean): Promise<boolean> {
if (name !== 'draft' || expanding || !dirty.value) {
lastDecision.value = `allowed: ${String(name)} ${expanding ? 'expanded' : 'collapsed'}`
return true
}
await new Promise(resolve => setTimeout(resolve, 400))
lastDecision.value = 'collapse of "draft" blocked: unsaved changes'
return false
}
</script>
<template>
<div class="grid gap-3">
<GrCollapse v-model="expanded" :before-change="beforeChange">
<GrCollapseItem name="draft" title="Draft with unsaved changes">
<div class="grid gap-3">
<GrFormField label="Draft title">
<GrInput v-model="draft" size="sm" />
</GrFormField>
<GrSwitch v-model="dirty" size="sm">
Treat the draft as unsaved
</GrSwitch>
</div>
</GrCollapseItem>
<GrCollapseItem name="history" title="Change history">
This section opens and closes freely — the guard only protects the draft above.
</GrCollapseItem>
</GrCollapse>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Last guard decision: <span class="font-semibold text-[var(--gr-fg)]">{{ lastDecision }}</span>
</div>
</div>
</template>