Accordion

Stacked sections that expand and collapse, one at a time or several at once.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add accordion
pnpm dlx ermineui add accordion
yarn dlx ermineui add accordion
bunx ermineui add accordion

That copies 6 files into src/components/ui/accordion/, 2 helpers into src/lib/ermine/. Files you already have are kept.

Use

import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
<Accordion v-model="open" type="multiple">
  <AccordionItem value="domains">
    <AccordionTrigger>How do I add a custom domain?</AccordionTrigger>
    <AccordionContent>Open the Domains tab and add the DNS record it shows.</AccordionContent>
  </AccordionItem>
</Accordion>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Accordion.vuesrc/components/ui/accordion/Accordion.vue
AccordionContent.vuesrc/components/ui/accordion/AccordionContent.vue
AccordionItem.vuesrc/components/ui/accordion/AccordionItem.vue
AccordionTrigger.vuesrc/components/ui/accordion/AccordionTrigger.vue
context.tssrc/components/ui/accordion/context.ts
index.tssrc/components/ui/accordion/index.ts
collection.ts helpersrc/lib/ermine/collection.ts
context.ts helpersrc/lib/ermine/context.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Accordion.vue 155 lines
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { arrowTarget } from '../../lib/collection'
import { provideAccordion } from './context'

const props = withDefaults(
  defineProps<{
    /** "single" keeps one section open at a time; "multiple" lets any number be open. */
    type?: 'single' | 'multiple'
    /** In single mode, let the open section be closed again. */
    collapsible?: boolean
    /** The section (or sections) open at the start when you are not using v-model. */
    defaultValue?: string | string[]
    disabled?: boolean
  }>(),
  { type: 'single', collapsible: true },
)

/** The open section's value, or an array of values in multiple mode. */
const value = defineModel<string | string[]>()
if (value.value === undefined && props.defaultValue !== undefined) value.value = props.defaultValue

const openValues = computed(() => {
  const current = value.value
  if (Array.isArray(current)) return current
  return current === undefined || current === '' ? [] : [current]
})

function toggle(item: string) {
  const isOpen = openValues.value.includes(item)
  if (props.type === 'multiple') {
    value.value = isOpen ? openValues.value.filter((open) => open !== item) : [...openValues.value, item]
  } else if (!isOpen) {
    value.value = item
  } else if (props.collapsible) {
    value.value = undefined
  }
}

provideAccordion({
  isOpen: (item) => openValues.value.includes(item),
  toggle,
  disabled: toRef(props, 'disabled'),
})

function onKeydown(event: KeyboardEvent) {
  if (!(event.target instanceof HTMLElement) || !event.target.matches('.er-accordion-trigger')) return
  const root = event.currentTarget as HTMLElement
  const triggers = Array.from(root.querySelectorAll<HTMLElement>('.er-accordion-trigger:not(:disabled)'))
  const next = arrowTarget(event, triggers, 'vertical')
  if (!next) return
  event.preventDefault()
  next.focus()
}
</script>

<template>
  <div class="er-accordion" @keydown="onKeydown">
    <slot />
  </div>
</template>

<style>
@layer components {
  .er-accordion {
    width: 100%;
  }

  .er-accordion-item {
    border-bottom: 1px solid var(--er-border);
  }

  .er-accordion-heading {
    margin: 0;
    font: inherit;
  }

  .er-accordion-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    width: 100%;
    padding: 1rem 0;
    border: 0;
    border-radius: var(--er-radius-sm);
    background: transparent;
    color: var(--er-foreground);
    font-family: inherit;
    font-size: 0.875rem;
    font-weight: var(--er-weight-medium);
    line-height: 1.25rem;
    text-align: left;
    cursor: pointer;
  }

  .er-accordion-trigger:hover {
    text-decoration: underline;
    text-underline-offset: 4px;
  }

  .er-accordion-trigger:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-accordion-trigger:disabled {
    opacity: 0.5;
    pointer-events: none;
  }

  .er-accordion-trigger svg {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    color: var(--er-muted-foreground);
    transition: transform var(--er-duration) var(--er-ease);
  }

  .er-accordion-trigger[aria-expanded='true'] svg {
    transform: rotate(180deg);
  }

  /* Animates to the content's own height: a grid row going from 0fr to 1fr needs no measuring. */
  .er-accordion-content {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows var(--er-duration) var(--er-ease);
  }

  .er-accordion-content[data-state='open'] {
    grid-template-rows: 1fr;
  }

  .er-accordion-content > div {
    min-height: 0;
    overflow: hidden;
    /* Hidden outright once it has closed, so its text cannot be selected or found while out of sight. */
    visibility: hidden;
    transition: visibility 0s linear var(--er-duration);
  }

  .er-accordion-content[data-state='open'] > div {
    visibility: visible;
    transition-delay: 0s;
  }

  .er-accordion-body {
    padding-bottom: 1rem;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
  }
}
</style>
AccordionContent.vue 22 lines
<script setup lang="ts">
import { useAccordionItem } from './context'

const item = useAccordionItem()
</script>

<template>
  <!-- Closed content stays in the page so it can animate, but inert keeps it out of Tab order and screen readers. -->
  <div
    :id="item.contentId"
    role="region"
    class="er-accordion-content"
    :aria-labelledby="item.triggerId"
    :data-state="item.open.value ? 'open' : 'closed'"
    :inert="!item.open.value"
  >
    <div>
      <div class="er-accordion-body"><slot /></div>
    </div>
  </div>
</template>
AccordionItem.vue 27 lines
<script setup lang="ts">
import { computed, useId } from 'vue'
import { provideAccordionItem, useAccordion } from './context'

const props = defineProps<{
  value: string
  disabled?: boolean
}>()

const accordion = useAccordion()
const open = computed(() => accordion.isOpen(props.value))

provideAccordionItem({
  open,
  disabled: computed(() => props.disabled || accordion.disabled.value),
  toggle: () => accordion.toggle(props.value),
  triggerId: useId(),
  contentId: useId(),
})
</script>

<template>
  <div class="er-accordion-item" :data-state="open ? 'open' : 'closed'">
    <slot :open="open" />
  </div>
</template>
AccordionTrigger.vue 33 lines
<script setup lang="ts">
import { useAccordionItem } from './context'

withDefaults(
  defineProps<{
    /** Heading tag wrapped around the button, to fit the page's outline. */
    heading?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
  }>(),
  { heading: 'h3' },
)

const item = useAccordionItem()
</script>

<template>
  <component :is="heading" class="er-accordion-heading">
    <button
      :id="item.triggerId"
      type="button"
      class="er-accordion-trigger"
      :aria-expanded="item.open.value"
      :aria-controls="item.contentId"
      :disabled="item.disabled.value"
      @click="item.toggle()"
    >
      <slot />
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m6 9 6 6 6-6" />
      </svg>
    </button>
  </component>
</template>
context.ts 20 lines
import type { ComputedRef, Ref } from 'vue'
import { createContext } from '../../lib/context'

export interface AccordionContext {
  isOpen: (value: string) => boolean
  toggle: (value: string) => void
  disabled: Ref<boolean>
}

export interface AccordionItemContext {
  open: ComputedRef<boolean>
  disabled: ComputedRef<boolean>
  toggle: () => void
  triggerId: string
  contentId: string
}

export const [provideAccordion, useAccordion] = createContext<AccordionContext>('Accordion')
export const [provideAccordionItem, useAccordionItem] = createContext<AccordionItemContext>('AccordionItem')
index.ts 6 lines
/** Accordion — stacked sections that expand and collapse, one at a time or several at once. */
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'