Display
Accordion
Bloques que se abren y se cierran, solos o de uno en uno
Preview
¿Cómo anima la altura?
Con una rejilla de una sola fila que pasa de 0fr a 1fr. El navegador calcula la altura real y la transición la recorre entera, sin medir nada con JavaScript.
¿Y al cerrar?
Un details oculta su contenido en el acto. Por eso la transición incluye también content-visibility, que lo mantiene pintado hasta que la fila termina de cerrarse.
¿Qué hace exclusive?
Da el mismo name a todos los details del grupo, y el navegador deja abierto solo uno. Ni una línea de 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 | Los bloques en orden, cada uno con { label, content, open? }. label es la fila que se pulsa, content lo que despliega y open lo deja abierto al cargar. |
| size | "sm" | "md" | ResponsiveSize | required | Tamaño del texto y padding de cada fila. Fijo o responsive. |
| exclusive | boolean | — | Deja abierto un solo bloque a la vez. Sin ella, cada uno se abre por su cuenta. |