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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add accordionpnpm dlx ermineui add accordionyarn dlx ermineui add accordionbunx ermineui add accordionThat 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
| File | Where it goes |
|---|---|
Accordion.vue | src/components/ui/accordion/Accordion.vue |
AccordionContent.vue | src/components/ui/accordion/AccordionContent.vue |
AccordionItem.vue | src/components/ui/accordion/AccordionItem.vue |
AccordionTrigger.vue | src/components/ui/accordion/AccordionTrigger.vue |
context.ts | src/components/ui/accordion/context.ts |
index.ts | src/components/ui/accordion/index.ts |
collection.ts helper | src/lib/ermine/collection.ts |
context.ts helper | src/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'