ATOM
Components (registry)Layout

Accordion

Click a row and its height opens while the icon flips; one-open or many. FAQs and progressive disclosure.

Preview

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: boolean
  • 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 (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>
ClasePropósito
accordionroot
accordion__chevronelement
accordion__contentelement
accordion__content-innerelement
accordion__content-wrapperelement
accordion__headingelement
accordion__itemelement
accordion__item--openelement
accordion__triggerelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbordernone(unparsed)
allbgnone(unparsed)
allfg#525252muted.foreground
allhover-fg#525252muted.foreground

Animaciones

PropiedadDuraciónEasing
color0.15sease
transform0.3scubic-bezier(0.19
1
0.22
1)
grid-template-rows0.3scubic-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

components-react
components/atoms/Accordion.tsx
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

Webflowaccordion

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-visible on .accordion__trigger:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .accordion__chevron svgcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion__item--open .accordion__chevroncompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion__trigger[aria-expanded='true'] .accordion__chevroncompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion__item--open .accordion__content-wrappercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion__trigger[aria-expanded='true'] ~ .accordion__content-wrappercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion__item:has(.accordion__trigger[aria-expanded='true']) .accordion__content-wrappercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion__trigger:focus-visiblecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)

Tras pegar: Create component → nombre Atom / Accordion → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categorialayout
Lectura...
Visitas...
Ayuda?Slack

Componente

Accordion

Source

components-react / cssDisponible via MCP: atom_uikit_source("accordion")
Abrir en Storybook