Frappe UIFrappe UI

Accordion

Stacks sections of content behind labelled headers that expand and collapse. Useful for FAQs, settings groups, and anywhere vertical space is at a premium.

ExperimentalAccordion ships from frappe-ui/experimental while its API settles, so it is exempt from the usual deprecation policy and can change shape or disappear in any release.

ts
import { Accordion } from 'frappe-ui/experimental'
import type { AccordionItem, AccordionProps } from 'frappe-ui/experimental'

Each item needs a value — it is the item's identity and the key modelValue / defaultValue refer to, so it must stay stable as items is reordered or filtered.

Default

Orders are processed within 1–2 business days and typically arrive within 5–7 business days.

vue
<script setup lang="ts">
import { Accordion } from '..'

const items = [
  {
    value: 'shipping',
    title: 'How long does shipping take?',
    content:
      'Orders are processed within 1–2 business days and typically arrive within 5–7 business days.',
  },
  {
    value: 'returns',
    title: 'What is your return policy?',
    content:
      'You can return any unused item within 30 days of delivery for a full refund.',
  },
  {
    value: 'support',
    title: 'How do I contact support?',
    content:
      'Reach out to our team any time at [email protected] and we will get back to you within a day.',
  },
]
</script>

<template>
  <div class="w-full max-w-lg">
    <Accordion :items="items" default-value="shipping" />
  </div>
</template>

Multiple

Account-wide preferences such as language, timezone, and theme.

Manage your plan, payment methods, and download past invoices.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Accordion } from '..'

const open = ref<string[]>(['general', 'billing'])

const items = [
  {
    value: 'general',
    title: 'General',
    content: 'Account-wide preferences such as language, timezone, and theme.',
  },
  {
    value: 'billing',
    title: 'Billing',
    content: 'Manage your plan, payment methods, and download past invoices.',
  },
  {
    value: 'notifications',
    title: 'Notifications',
    content: 'Choose which email and in-app notifications you want to receive.',
  },
]
</script>

<template>
  <div class="w-full max-w-lg">
    <Accordion type="multiple" v-model="open" :items="items" />
  </div>
</template>

Controlled

Bind v-model to own the open state in the parent — external controls can drive it, and you can react to every change.

Open: overview

A short summary of the project.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from 'frappe-ui'
import { Accordion } from '..'

// "Controlled" means the parent owns the open state via v-model — the
// Accordion reflects `open`, and external controls can drive it too.
const items = [
  {
    value: 'overview',
    title: 'Overview',
    content: 'A short summary of the project.',
  },
  {
    value: 'specs',
    title: 'Specs',
    content: 'Detailed technical specifications.',
  },
  {
    value: 'pricing',
    title: 'Pricing',
    content: 'Plans, limits, and billing details.',
  },
]

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

function expandAll() {
  open.value = items.map((item) => item.value)
}

function collapseAll() {
  open.value = []
}
</script>

<template>
  <div class="w-full max-w-lg space-y-3">
    <div class="flex items-center gap-2">
      <Button label="Expand all" @click="expandAll" />
      <Button label="Collapse all" @click="collapseAll" />
      <span class="ml-auto text-p-sm text-ink-gray-5">
        Open: {{ open.length ? open.join(', ') : 'none' }}
      </span>
    </div>
    <Accordion type="multiple" v-model="open" :items="items" />
  </div>
</template>

Icons

vue
<script setup lang="ts">
import { Accordion } from '..'

const items = [
  {
    value: 'profile',
    title: 'Profile',
    icon: 'lucide-user',
    content: 'Update your name, avatar, and public profile details.',
  },
  {
    value: 'security',
    title: 'Security',
    icon: 'lucide-shield',
    content: 'Change your password and manage two-factor authentication.',
  },
  {
    value: 'archived',
    title: 'Archived (disabled)',
    icon: 'lucide-archive',
    content: 'This section is read-only.',
    disabled: true,
  },
]
</script>

<template>
  <div class="w-full max-w-lg">
    <Accordion :items="items">
      <template #item-content="{ item }">
        <p class="text-p-base text-ink-gray-6">{{ item.content }}</p>
      </template>
    </Accordion>
  </div>
</template>

Header suffix

Use the #item-suffix slot for trailing, non-interactive header content such as a count or Badge.

Messages waiting for a reply.

vue
<script setup lang="ts">
import { Badge } from 'frappe-ui'
import { Accordion } from '..'

const items = [
  { value: 'inbox', title: 'Inbox', content: 'Messages waiting for a reply.' },
  {
    value: 'drafts',
    title: 'Drafts',
    content: 'Unsent messages saved for later.',
  },
  {
    value: 'spam',
    title: 'Spam',
    content: 'Filtered messages we think are junk.',
  },
]

// Aligned to `items` by index — the slot exposes `{ item, index }`.
const badges = [
  { label: '12', theme: 'blue' },
  { label: '3', theme: 'gray' },
  { label: 'New', theme: 'red' },
] as const
</script>

<template>
  <div class="w-full max-w-lg">
    <Accordion :items="items" default-value="inbox">
      <template #item-suffix="{ index }">
        <Badge
          :label="badges[index].label"
          :theme="badges[index].theme"
          variant="subtle"
        />
      </template>
    </Accordion>
  </div>
</template>

API Reference

Show types
typescript
import type { Component } from 'vue'

export interface AccordionItem {
  /**
   * Unique value identifying the item, and the open/closed key for
   * `modelValue` / `defaultValue`. Must be stable across renders: it is the
   * item's identity, so deriving it from position would move the open state
   * to a different panel whenever `items` is reordered or filtered.
   */
  value: string

  /** Text shown in the trigger header. */
  title: string

  /** Content shown when the item is expanded. */
  content?: string

  /**
   * Optional icon shown before the title. Pass a `lucide-*` class string for
   * the recommended class-based form, or a Vue component for custom icons.
   */
  icon?: string | Component

  /** Disables this item, preventing it from being toggled. */
  disabled?: boolean
}

export interface AccordionProps {
  /** Element/component used to render the accordion container. */
  as?: string

  /**
   * Heading tag each trigger is wrapped in. The WAI-ARIA accordion pattern
   * requires triggers to sit inside a heading so assistive tech can navigate
   * by heading; pick the level that fits the surrounding outline.
   */
  headingTag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'

  /** List of items to render. */
  items: AccordionItem[]

  /**
   * Whether one (`single`) or several (`multiple`) items can be open at the
   * same time. Determines the shape of `modelValue` / `defaultValue`:
   * a `string` for `single`, a `string[]` for `multiple`.
   */
  type?: 'single' | 'multiple'

  /**
   * When `type` is `single`, allows the open item to be collapsed again by
   * clicking its trigger. Ignored for `multiple`.
   */
  collapsible?: boolean

  /** Currently open item value(s). Use with `v-model`. */
  modelValue?: string | string[]

  /** Open item value(s) for uncontrolled usage. */
  defaultValue?: string | string[]

  /** Disables the whole accordion. */
  disabled?: boolean
}
as
string

Element/component used to render the accordion container.

headingTag
= "h3"
"h1" | "h2" | "h3" | "h4" | "h5" | "h6"

Heading tag each trigger is wrapped in. The WAI-ARIA accordion pattern requires triggers to sit inside a heading so assistive tech can navigate by heading; pick the level that fits the surrounding outline.

items*
AccordionItem[]

List of items to render.

type
= "single"
"single" | "multiple"

Whether one (`single`) or several (`multiple`) items can be open at the same time. Determines the shape of `modelValue` / `defaultValue`: a `string` for `single`, a `string[]` for `multiple`.

collapsible
= true
boolean

When `type` is `single`, allows the open item to be collapsed again by clicking its trigger. Ignored for `multiple`.

modelValue
string | string[]

Currently open item value(s). Use with `v-model`.

defaultValue
string | string[]

Open item value(s) for uncontrolled usage.

disabled
= false
boolean

Disables the whole accordion.

item-label
{ item: AccordionItem; index: number; }

Custom renderer for an item's trigger label. Receives `{ item, index }`.

item-suffix
{ item: AccordionItem; index: number; }

Trailing, non-interactive header content (e.g. a `Badge` or count) shown before the chevron. Receives `{ item, index }`.

item-content
{ item: AccordionItem; index: number; }

Custom renderer for an item's panel. Receives `{ item, index }`.

update:modelValue
[value: string | string[] | undefined]

Fired when the model value changes.