GrCollapse

Package: @feugene/granularitycoreGroup: overlays

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 timeaccordion closes the previous one;
  • the sections are needed by a blind userheadingLevel sets a heading of the right level, and walking by headings works;
  • opening has to be interceptedbeforeChange postpones the expansion until a load or a confirmation.

When to take something else

NeedTake
The sections are switched rather than expandedGrTabs
The sections of a form follow one anotherGrFormSection
The data is nestedGrTree
The content opens on top of the pageGrDialog / 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…

Code
<GrCollapse />

Install

npm i @feugene/granularity

Import

import { GrCollapse } from '@feugene/granularity/components/GrCollapse'

API

Props

PropTypedefaultDescription
modelValueGrCollapseValue | GrCollapseValue[] | undefinedundefined
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the sections. Unset — it comes from `GrConfigProvider`, otherwise `md`.
headingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedThe level of the section headings (`h2`…`h6`) — to fit the structure of the page.
accordionboolean | undefinedfalse
dividedboolean | undefinedundefined
borderlessboolean | undefinedundefined
expandIconPosition"end" | "start" | undefinedundefinedThe side the chevron is on relative to the heading.
beforeChangeGrCollapseBeforeChange | undefinedundefined
emptyboolean | undefinedundefinedWhether 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.
emptyTextstring | undefinedundefinedThe text of the empty state. The `#empty` slot is stronger.

Slots

SlotTypeDescription
defaultanyThe sections of the accordion (`GrCollapseItem`).
emptyanyThe content of the empty state instead of the default text.

Events

EventTypeDescription
update:modelValue[value: GrCollapseModelValue]
change[value: GrCollapseModelValue]

Examples 6

Empty accordion speaks for itself

Invoices, payment method and tax details.

Roles, invitations and seat limits.
Own markup instead of the default text — the `empty` slot.

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

Open panel:Profile setup

Keep onboarding steps in a single accordion so only one block stays expanded at a time.

Group less-frequent preferences into a secondary panel without overwhelming the main settings form.

Reserve the last section for sensitive actions or audit details.

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

Multi-expand mode works well for dense dashboards where several sections should stay visible together.

Key financial highlights, ownership notes and recent approvals can stay open side by side.

Use a custom title slot when you need counters, badges or richer inline status markers.

Keep audit notes collapsed by default until the operator explicitly opens them.

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

Switch the whole collapse to a read-only state during background sync or permission checks.

Some items can remain unavailable even when the rest of the group is interactive.

Disabled styling is inherited from the parent and still preserves the overall layout.

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

Статус секции живёт в самом заголовке: подсветка строки накрывает его целиком, и правый край держат отступы карточки, а не отдельная колонка.

Без рамки и разделителей строку структурирует только hover — поэтому он обязан доходить до правого края, а не обрываться на середине.

Шеврон слева читается как дерево в сайдбаре, справа — как классический аккордеон.

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

This section opens and closes freely — the guard only protects the draft above.
Last guard decision:

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

Component documentationAll components