Display
Accordion
Sections that open and close, freely or one at a time
Preview
How does the height animate?
With a one-row grid that goes from 0fr to 1fr. The browser works out the real height and the transition covers all of it, with nothing measured in JavaScript.
And when it closes?
A details element hides its content at once. That is why the transition also covers content-visibility, which keeps it painted until the row has finished closing.
What does exclusive do?
It gives every details in the group the same name, and the browser keeps only one open. Not a line of JavaScript.
size
exclusive
Code
import css from "./Accordion.module.css";
import { useId } from "react";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
type Size = "sm" | "md";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface AccordionItem {
label: string;
content: string;
open?: boolean;
}
interface AccordionProps {
items: AccordionItem[];
size: Size | ResponsiveSize;
exclusive?: boolean;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Accordion({
items,
size,
exclusive,
}: AccordionProps) {
// Details sharing a name are one group, and the browser keeps at most one open.
const groupName = useId();
return (
<div
className={`
${css.accordion}
${getSizeClasses(size)}
`}
>
{items.map((item) => (
<details
key={item.label}
className={css.accordion_item}
name={exclusive ? groupName : undefined}
open={item.open}
>
<summary className={css.item_label}>
{item.label}
<CaretDownIcon className={css.label_caret} weight="bold" aria-hidden="true" />
</summary>
<p className={css.item_content}>{item.content}</p>
</details>
))}
</div>
);
}
.accordion {
width: 100%;
&.sm {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
&.sm_base {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_base {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.sm_mobile {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_mobile {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_tablet {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_desktop {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_wide {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
.accordion_item {
border-bottom: 1px solid var(--color-border);
/* 0fr → 1fr animates to the content's height; the 0 floor drops its
padding, a step at both ends. content-visibility, allowed to
transition, keeps it painted until the track is shut. */
&::details-content {
display: grid;
grid-template-rows: minmax(0, 0fr);
overflow: clip;
@media (prefers-reduced-motion: no-preference) {
transition:
grid-template-rows 0.3s ease,
content-visibility 0.3s ease allow-discrete;
}
}
&[open]::details-content {
grid-template-rows: minmax(0, 1fr);
}
.item_label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-16);
padding: var(--accordion-label-padding) 0;
font-weight: var(--weight-500);
list-style: none;
cursor: pointer;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
/* Safari draws its own marker whatever list-style says. */
&::-webkit-details-marker {
display: none;
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.label_caret {
flex-shrink: 0;
@media (prefers-reduced-motion: no-preference) {
transition: transform 0.3s ease;
}
}
}
&[open] .label_caret {
transform: rotate(180deg);
}
.item_content {
max-width: 60ch;
padding-bottom: var(--accordion-content-padding);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
}
}
<script setup lang="ts">
import { computed, useId } from "vue";
import { PhCaretDown } from "@phosphor-icons/vue";
type Size = "sm" | "md";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface AccordionItem {
label: string;
content: string;
open?: boolean;
}
interface AccordionProps {
items: AccordionItem[];
size: Size | ResponsiveSize;
exclusive?: boolean;
}
const { size } = defineProps<AccordionProps>();
// Details sharing a name are one group, and the browser keeps at most one open.
const groupName = useId();
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
</script>
<template>
<div class="accordion" :class="sizeClasses">
<details
v-for="item in items"
:key="item.label"
class="accordion_item"
:name="exclusive ? groupName : undefined"
:open="item.open"
>
<summary class="item_label">
{{ item.label }}
<PhCaretDown class="label_caret" weight="bold" aria-hidden="true" />
</summary>
<p class="item_content">{{ item.content }}</p>
</details>
</div>
</template>
<style scoped>
.accordion {
width: 100%;
&.sm {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
&.sm_base {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_base {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.sm_mobile {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_mobile {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_tablet {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_desktop {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--accordion-label-padding: var(--space-12);
--accordion-content-padding: var(--space-16);
font-size: var(--text-14);
}
&.md_wide {
--accordion-label-padding: var(--space-16);
--accordion-content-padding: var(--space-20);
font-size: var(--text-16);
}
}
.accordion_item {
border-bottom: 1px solid var(--color-border);
/* 0fr → 1fr animates to the content's height; the 0 floor drops its
padding, a step at both ends. content-visibility, allowed to
transition, keeps it painted until the track is shut. */
&::details-content {
display: grid;
grid-template-rows: minmax(0, 0fr);
overflow: clip;
@media (prefers-reduced-motion: no-preference) {
transition:
grid-template-rows 0.3s ease,
content-visibility 0.3s ease allow-discrete;
}
}
&[open]::details-content {
grid-template-rows: minmax(0, 1fr);
}
.item_label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-16);
padding: var(--accordion-label-padding) 0;
font-weight: var(--weight-500);
list-style: none;
cursor: pointer;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
/* Safari draws its own marker whatever list-style says. */
&::-webkit-details-marker {
display: none;
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.label_caret {
flex-shrink: 0;
@media (prefers-reduced-motion: no-preference) {
transition: transform 0.3s ease;
}
}
}
&[open] .label_caret {
transform: rotate(180deg);
}
.item_content {
max-width: 60ch;
padding-bottom: var(--accordion-content-padding);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| items | AccordionItem[] | required | The sections in order, each with { label, content, open? }. label is the row you press, content is what it unfolds and open leaves it open on load. |
| size | "sm" | "md" | ResponsiveSize | required | Text size and padding of each row. Fixed or responsive. |
| exclusive | boolean | — | Keeps a single section open at a time. Without it, each one opens on its own. |