Accordion
Click a row and its height opens while the icon flips; one-open or many. FAQs and progressive disclosure.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
FAQ stack:
```tsx import { Accordion, AccordionItem } from '@/components/atoms/Accordion';
<Accordion> <AccordionItem title="What is Atom UIKit?" defaultOpen> A shared design system for product UI. </AccordionItem> <AccordionItem title="How do I install a component?"> Use the registry / MCP install flow for your stack. </AccordionItem> </Accordion> ```
## Accesibilidad
- Each item needs a clear `title` (the control name). Prefer one `defaultOpen` for progressive disclosure when starting a long FAQ. - Do not put critical required form fields only inside a collapsed item.
## Cuándo no usar
- Mutually exclusive section switching with a selected tab look → `Tabs`. - Single collapsible without siblings → a disclosure/`details` pattern may be lighter.
## Criterio de uso
- Usa Accordion para progressive disclosure: mostrar detalles bajo demanda sin abandonar el contexto. - Mantén títulos independientes y accionables; si el usuario debe comparar secciones simultáneamente, permite múltiples abiertos o usa otro patrón. - El contenido crítico para completar una tarea no debe quedar oculto por defecto sin una señal clara.
## Gotchas
- El trigger debe ser un botón con `aria-expanded`; no anides enlaces, botones u otros controles interactivos dentro del título. - `defaultOpen` pertenece a cada `AccordionItem`, no al Accordion global.
Uso
import { Accordion, AccordionItem } from @/components/atoms/Accordion;
<Accordion>
<AccordionItem title="Shipping" defaultOpen>
Ships in 2–3 days.
</AccordionItem>
<AccordionItem title="Returns">
30-day window.
</AccordionItem>
</Accordion>Props
title: string (required)defaultOpen: booleanpagina: * bajo un h2 de seccion toca h3, y saltarse un nivel rompe el indice de * encabezados del lector de pantalla. */ headingLevel?: 2 | 3 | 4 | 5 | 6 (required)children: ReactNode (required)
Gotchas
- react
defaultOpen lives on AccordionItem, not Accordion. Pass it per item: <AccordionItem title="…" defaultOpen>.
- a11y
Trigger is a button with aria-expanded; do not nest interactive elements inside title.
Anatomía CSS
<div class="accordion"> <span class="accordion__chevron"></span> <span class="accordion__content"></span> <span class="accordion__content-inner"></span> <span class="accordion__content-wrapper"></span> <span class="accordion__heading"></span> <span class="accordion__item"></span> <span class="accordion__item--open"></span> <span class="accordion__trigger"></span> </div>
| Clase | Propósito |
|---|---|
accordion | root |
accordion__chevron | element |
accordion__content | element |
accordion__content-inner | element |
accordion__content-wrapper | element |
accordion__heading | element |
accordion__item | element |
accordion__item--open | element |
accordion__trigger | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | border | none | (unparsed) |
| all | bg | none | (unparsed) |
| all | fg | #525252 | muted.foreground |
| all | hover-fg | #525252 | muted.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
color | 0.15s | ease |
transform | 0.3s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
grid-template-rows | 0.3s | cubic-bezier(0.19 |
1 | | |
0.22 | | |
1) | | |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Accordion
Collapsible content sections. Single or multiple open.
All colors via semantic tokens. Spacing via token values.
Parts: .accordion, .accordion__item, .accordion__trigger, .accordion__content
------------------------------------------------------------------------- */
.accordion {
width: 100%;
}
.accordion__item {
border-bottom: 1px solid #e5e5e5;
}
/* La pregunta va dentro de un heading real y el heading solo envuelve al
boton. Es lo que deja que un lector de pantalla liste las preguntas en su
indice de encabezados, y lo que hace que la FAQ cuente como contenido con
jerarquia. El reset es obligatorio: los margenes y el tamano por defecto del
h3 romperian el ritmo de la lista. */
.accordion__heading {
margin: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.accordion__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 16px 0;
border: none;
background: none;
/* La familia se DECLARA, no se hereda. `inherit` parecia correcto y no lo era:
depende de que el host haya puesto la fuente en el body, y hay hosts que no
lo hacen — atomchat.io es uno. El resultado es una FAQ en la fuente del
sistema dentro de un sitio tipografiado. El resto de componentes del DS ya
la declaran; el accordion era la excepcion. */
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
font-weight: 500;
line-height: 1.5;
color: #0a0a0a;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease;
}
.accordion__trigger:hover {
color: #525252;
}
.accordion__trigger:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: 2px;
border-radius: 4px;
}
.accordion__chevron {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
color: #525252;
transition: transform 0.3s cubic-bezier(0.19, 1, 0.22, 1);
}
.accordion__chevron svg {
width: 100%;
height: 100%;
}
.accordion__item--open .accordion__chevron {
transform: rotate(180deg);
}
/* -------------------------------------------------------------------------
Segundo canal de estado: aria-expanded.
React pinta .accordion__item--open porque tiene el estado en el componente.
En HTML plano (Webflow) lo mueve el behavior initAccordion, y a un behavior
del DS le esta prohibido escribir clases BEM canonicas: son del canal de
pintura. Escribe aria-expanded, que un accordion necesita igualmente para
ser accesible, y ese mismo atributo pinta aqui.
Asi no hay dos fuentes de verdad que se puedan desincronizar: en React manda
la clase, en HTML plano manda el atributo, y ninguna vista tiene las dos.
------------------------------------------------------------------------- */
.accordion__trigger[aria-expanded='true'] .accordion__chevron {
transform: rotate(180deg);
}
/* Content: animated height via grid */
.accordion__content-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s cubic-bezier(0.19, 1, 0.22, 1);
}
.accordion__item--open .accordion__content-wrapper {
grid-template-rows: 1fr;
}
/* Hermano general y no hijo directo: entre el trigger y el wrapper cabe algun
div de maquetacion que el Designer inserte, y `+` se romperia en silencio. */
.accordion__trigger[aria-expanded='true'] ~ .accordion__content-wrapper {
grid-template-rows: 1fr;
}
/* El caso que el selector de hermanos NO cubre, y que es el marcado correcto:
la pregunta va dentro de un heading, asi que el trigger deja de ser hermano
del panel y pasa a ser sobrino. Con solo la regla de arriba el JS alternaba
aria-expanded pero el panel no abria — sin error, sin pista.
Se resuelve desde el item, que es el ancestro comun, y asi da igual cuanto
se anide el trigger. */
.accordion__item:has(.accordion__trigger[aria-expanded='true'])
.accordion__content-wrapper {
grid-template-rows: 1fr;
}
.accordion__content {
overflow: hidden;
min-height: 0;
}
.accordion__content-inner {
padding-bottom: 16px;
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
/* base y no sm: --font-size-sm son 12.8px, un tamano de pie de foto. La
respuesta de un accordion es texto de lectura corrida y se lee entera, asi
que va al mismo cuerpo que el resto del contenido. */
font-size: 16px;
line-height: 1.5;
color: #525252;
}
@media (prefers-reduced-motion: reduce) {
.accordion__content-wrapper,
.accordion__chevron {
transition-duration: 0ms;
}
}
Codigo fuente
import { useState, type ReactNode } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } const ChevronDown = () => ( <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M6 9l6 6 6-6" /> </svg> ); // ---- AccordionItem ---- export type AccordionItemProps = { title: string; defaultOpen?: boolean; /** * Nivel del encabezado que envuelve al disparador. Es prop y no un valor fijo * porque el nivel correcto depende de donde caiga el accordion en la pagina: * bajo un h2 de seccion toca h3, y saltarse un nivel rompe el indice de * encabezados del lector de pantalla. */ headingLevel?: 2 | 3 | 4 | 5 | 6; children: ReactNode; className?: string; }; export function AccordionItem({ title, defaultOpen = false, headingLevel = 3, children, className, }: AccordionItemProps) { const [open, setOpen] = useState(defaultOpen); const Heading = `h${headingLevel}` as const; return ( <div className={cn('accordion__item', open && 'accordion__item--open', className)}> {/* El heading envuelve al boton y no al reves: un <button> solo admite contenido de frase, y ademas asi la pregunta entra en el indice de encabezados de la pagina. */} <Heading className="accordion__heading"> <button type="button" className="accordion__trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open} > {title} <span className="accordion__chevron"><ChevronDown /></span> </button> </Heading> <div className="accordion__content-wrapper"> <div className="accordion__content"> <div className="accordion__content-inner"> {children} </div> </div> </div> </div> ); } // ---- Accordion ---- export type AccordionProps = { children: ReactNode; className?: string; }; export function Accordion({ children, className }: AccordionProps) { return <div className={cn('accordion', className)}>{children}</div>; }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Accordion) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.
Setup del sitio (una vez)
Custom Code → Head:
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">
Unsupported (no silencioso)
:focus-visibleon.accordion__trigger:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.accordion__chevron svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion__item--open .accordion__chevron— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion__trigger[aria-expanded='true'] .accordion__chevron— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion__item--open .accordion__content-wrapper— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion__trigger[aria-expanded='true'] ~ .accordion__content-wrapper— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion__item:has(.accordion__trigger[aria-expanded='true']) .accordion__content-wrapper— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion__trigger:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
Tras pegar: Create component → nombre Atom / Accordion → Publish.