ATOM
Components (registry)Layout

Accordion Morph

FAQ accordion where each answer pours out of its question pill like liquid (SVG goo filter), settling into a clean rounded panel. Pair with the accordion-morph-animation hook.

Preview

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

```tsx import { AccordionMorph } from '@atom-uikit/react'; import { initAccordionMorph } from '@atom-uikit/animations'; import { useEffect } from 'react';

const faqs = [ { question: 'Que hace diferentes a los marcos?', answer: 'Cada marco se lamina en carbono crudo...' }, { question: 'Cual es la garantia?', answer: 'Dos anos contra fallo estructural...' }, ];

export function Faq() { useEffect(() => initAccordionMorph(), []); return <AccordionMorph items={faqs} startOpen={0} />; } ```

```tsx // Dentro de un dashboard: sin goo, disclosure instantaneo (D5 canal codigo) <AccordionMorph items={faqs} animated={false} /> ```

## Accesibilidad

- El disclosure es **funcional**: con `prefers-reduced-motion` o `data-motion-exempt` abre y cierra instantaneo por DOM directo — nunca depende de que gsap cargue. - El behavior mantiene `aria-expanded` en el trigger y `aria-hidden` + `inert` en el panel; las flechas mueven el foco entre triggers con wrap. - La capa goo lleva `aria-hidden`: son formas decorativas, el contenido real vive fuera del filtro (el blur destruye el texto — por eso existen dos capas).

## Cuándo no usar

- FAQ sobria dentro de flujos densos o legales → usa `Accordion` (CSS-only, grid trick). - Lista de items plegables en un dashboard → usa `Accordion`; aqui el goo es protagonista y compite con los datos. - Mas de ~8 preguntas → el filtro por fila cuesta GPU; usa `Accordion` o pagina el FAQ.

Uso

<AccordionMorph items={[{ question: '...', answer: '...' }]} startOpen={0} />
// + una vez por pagina:
useEffect(() => initAccordionMorph(), []);

Props

PropTipoDefaultRango / opcionesWhatHow
gooStrengthnumber94–16 step 1 pxstdDeviation del blur goo — que tan espeso se ve el liquido al verter.9 es el balance firma; 4-6 para un vertido sutil en secciones densas; 12-16 solo en heros con una sola instancia (el blur alto cuesta GPU por fila).
multipleboolean`true` / `false`Permite varias respuestas abiertas a la vez.false para FAQs de marketing (foco en una respuesta); true solo en documentacion de referencia donde el lector compara respuestas.
startOpennumber-1-1–20 step 1 indexIndice de la fila abierta al montar (-1 = ninguna).0 cuando la primera pregunta ES el mensaje (pricing, objecion principal); -1 en FAQs largas para no empujar el fold.
animatedboolean`true` / `false`D5 canal codigo: false emite data-motion-exempt — disclosure instantaneo sin goo.true salvo listas dentro de dashboards o tablas (el goo es protagonista, no acompanante); reduced-motion ya degrada solo, no uses false para eso.

Gotchas

  • react

    El componente NO anima solo: emparejar con initAccordionMorph() de @atom-uikit/animations (gsap + CustomEase + SplitText globales). Sin el behavior es un disclosure estatico cerrado.

  • css-modules

    Pildora y panel pintan su fondo con --card + --border y el texto usa --card-foreground + opacity: siguen al tema (blanco en light, oscuro en dark). El liquido va en --secondary. No sobreescribir esos fondos: son el rol crisp que tapa el wobble del filtro.

  • a11y

    FUNCIONAL: reduced-motion y data-motion-exempt abren/cierran instantaneo via DOM directo — el disclosure jamas depende de gsap. aria-expanded/aria-hidden/inert los escribe el behavior.

  • layout

    El filtro SVG viaja como HtmlEmbed en el paste. El goo exige gsap+CustomEase en el footer del site; SplitText es opcional (sin el, la respuesta hace fade en vez de reveal por lineas).

Anatomía CSS

<div class="accordion-morph">
  <span class="accordion-morph__answer"></span>
  <span class="accordion-morph__answer-line"></span>
  <span class="accordion-morph__filter-svg"></span>
  <span class="accordion-morph__goo"></span>
  <span class="accordion-morph__goo-panel"></span>
  <span class="accordion-morph__goo-pill"></span>
  <span class="accordion-morph__icon"></span>
  <span class="accordion-morph__index"></span>
  <span class="accordion-morph__list"></span>
  <span class="accordion-morph__panel"></span>
  <span class="accordion-morph__panel-inner"></span>
  <span class="accordion-morph__question"></span>
  <span class="accordion-morph__row"></span>
  <span class="accordion-morph__trigger"></span>
</div>
ClasePropósito
accordion-morphroot
accordion-morph__answerelement
accordion-morph__answer-lineelement
accordion-morph__filter-svgelement
accordion-morph__gooelement
accordion-morph__goo-panelelement
accordion-morph__goo-pillelement
accordion-morph__iconelement
accordion-morph__indexelement
accordion-morph__listelement
accordion-morph__panelelement
accordion-morph__panel-innerelement
accordion-morph__questionelement
accordion-morph__rowelement
accordion-morph__triggerelement

Tokens resueltos

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

VariantePropValorToken
allbg#ffffffcard
allfg#0a0a0acard.foreground
allborder#e5e5e5border
allhover-bg#f5f5f5accent

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 Morph

   FAQ accordion donde la respuesta "se vierte" de la pildora como liquido
   (filtro goo). CSS es dueno del estado ESTATICO: pildora y panel pintan su
   propio fondo (por eso no hay twins "crisp" — ver accordion-morph.ts). El
   motion vive integro en packages/animations/src/accordion-morph.ts.

   La capa goo arranca invisible: solo el behavior la enciende. Sin JS (o con
   reduced-motion) el componente es un disclosure normal y completo.

   Colores: superficie = card + border (blanco en light, oscuro en dark — el
   bloque SIGUE al tema, decision Karen 2026-08-03; primary lo invertia).
   Jerarquia del texto via opacity tokens, no colores nuevos.

   Parts: .accordion-morph, __list, __row, __goo, __goo-pill, __goo-panel,
          __trigger, __index, __question, __icon, __panel, __panel-inner,
          __answer, __answer-line, __filter-svg
   ------------------------------------------------------------------------- */

/* Sin max-width propio: el ancho lo decide el layout que lo contenga
   (structure-only) — el 560px del original era el marco de su demo. */
.accordion-morph {
  position: relative;
  width: 100%;
}

.accordion-morph__filter-svg {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  pointer-events: none;
}

.accordion-morph__list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.accordion-morph__row {
  position: relative;
}

/* Capa filtrada: SOLO formas solidas — el filtro goo destruye el texto.
   Invisible hasta que el behavior la enciende (estado estatico sin halos). */
.accordion-morph__goo {
  position: absolute;
  inset: 0;
  z-index: 0;
  opacity: 0;
  pointer-events: none;
}

/* El liquido va en secondary, un paso mas oscuro que card y background en
   ambos temas: el cuello del vertido se lee como puente sutil, no invisible
   (card sobre background casi no contrasta) ni como mancha (primary gritaba). */
.accordion-morph__goo-pill,
.accordion-morph__goo-panel {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  background-color: #efefef;
  will-change: transform;
}

/* Radio constante — NUNCA se anima; el behavior lo aparca con inset detras
   de la capsula para que sus esquinas no asomen en reposo. */
.accordion-morph__goo-panel {
  border-radius: 16px;
}

/* La pildora pinta su propio fondo: elemento real y opaco ENCIMA del filtro,
   asi el wobble del blur queda tapado salvo en el cuello liquido. */
.accordion-morph__trigger {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 56px;
  padding: 16px 20px;
  background-color: #ffffff;
  border: 1px solid #e5e5e5;
  border-radius: 9999px;
  cursor: pointer;
  text-align: left;
  color: #0a0a0a;
  font-family: inherit;
  box-sizing: border-box;
}

.accordion-morph__trigger:hover {
  background-color: #f5f5f5;
}

.accordion-morph__trigger:focus-visible {
  outline: 2px solid var(--focus-ring-color);
  outline-offset: 2px;
}

.accordion-morph__index {
  flex-shrink: 0;
  min-width: 24px;
  font-size: 12.8px;
  font-weight: 500;
  color: #0a0a0a;
  opacity: 0.5;
}

.accordion-morph__question {
  flex: 1;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.45;
  color: #0a0a0a;
}

.accordion-morph__icon {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  color: #0a0a0a;
  opacity: 0.8;
  transform-origin: 50% 50%;
  will-change: transform;
}

.accordion-morph__panel {
  position: relative;
  z-index: 1;
  height: 0;
  overflow: hidden;
  will-change: height;
}

/* El panel tambien pinta su fondo (mismo rol crisp que la pildora). El
   margin-top ES el gap que el goo puentea — el behavior lo lee de
   --spacing-3 en runtime: cambiar el token mueve ambos a la vez. */
.accordion-morph__panel-inner {
  margin-top: 12px;
  padding: 20px;
  background-color: #ffffff;
  border: 1px solid #e5e5e5;
  border-radius: 16px;
}

.accordion-morph__answer {
  margin: 0;
  font-size: 12.8px;
  font-weight: 400;
  line-height: 1.45;
  letter-spacing: -0.02em;
  color: #0a0a0a;
  opacity: 0.8;
}

/* Mascara de linea para el reveal de SplitText: el clip vertical corta el
   texto que sube; el respiro em evita decapitar descendentes. */
.accordion-morph__answer-line {
  overflow-x: visible;
  overflow-y: clip;
  padding-bottom: 0.18em;
  margin-bottom: -0.18em;
}

@media (max-width: 767px) {
  .accordion-morph__trigger {
    padding: 12px 16px;
    gap: 12px;
  }

  .accordion-morph__panel-inner {
    padding: 16px;
  }
}

@media (max-width: 479px) {
  .accordion-morph__index {
    display: none;
  }
}

Codigo fuente

components-react
components/molecules/AccordionMorph.tsx
import { useId, type ReactNode } from 'react';

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

/**
 * Morphing accordion — anatomia completa del organismo goo (filtro SVG, capa
 * liquida, pildoras y paneles con fondo propio). Este componente NO anima:
 * emite el contrato data-* y la interactividad la pone
 * `initAccordionMorph()` de @atom-uikit/animations (patron del DS: el
 * componente es tonto, el behavior orquesta por data-attrs).
 *
 * D5 (defaults por canal): en codigo el motion es prop editable — `animated`
 * default true; false emite data-motion-exempt y el behavior abre/cierra
 * instantaneo (disclosure funcional intacto, cero goo).
 */

export type AccordionMorphItem = {
  question: string;
  answer: ReactNode;
};

// Sin JSDoc por prop a proposito: what/how/rangos viven en meta.agent del
// registry (SSOT) y el extractor de props no soporta comentarios inline.
export type AccordionMorphProps = {
  items: AccordionMorphItem[];
  multiple?: boolean;
  startOpen?: number;
  gooStrength?: number;
  animated?: boolean;
  idBase?: string;
  className?: string;
};

const PlusIcon = () => (
  <svg
    className="accordion-morph__icon"
    data-accordion-morph-icon
    viewBox="0 0 16 16"
    fill="none"
    aria-hidden="true"
  >
    <path d="M8 1V15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    <path d="M1 8H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
  </svg>
);

export function AccordionMorph({
  items,
  multiple = false,
  startOpen = -1,
  gooStrength = 9,
  animated = true,
  idBase,
  className,
}: AccordionMorphProps) {
  // useId depende del ORDEN GLOBAL de renders del proceso: en el emitter del
  // registry eso hizo el artefacto no-reproducible entre maquinas (el gate de
  // deriva lo cazo en CI). idBase explicito = ids deterministas para artefactos
  // y SSR; useId queda como fallback comodo para apps React.
  const reactId = useId();
  const uid = idBase ?? reactId;

  return (
    <div
      className={cn('accordion-morph', className)}
      data-accordion-morph
      data-accordion-morph-multiple={multiple ? 'true' : undefined}
      data-accordion-morph-start-open={startOpen >= 0 ? String(startOpen) : undefined}
      data-accordion-morph-goo-strength={String(gooStrength)}
      data-motion-exempt={animated ? undefined : ''}
    >
      {/* Template del filtro goo: el behavior clona uno POR FILA para que
          transiciones concurrentes no compartan rampa de blur. */}
      <svg className="accordion-morph__filter-svg" data-accordion-morph-filter="" width="0" height="0" aria-hidden="true" focusable="false">
        <defs>
          <filter
            id={`${uid}-goo`}
            colorInterpolationFilters="sRGB"
            x="-50%"
            y="-50%"
            width="200%"
            height="200%"
          >
            <feGaussianBlur in="SourceGraphic" stdDeviation="0" result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -10"
              result="goo"
            />
          </filter>
        </defs>
      </svg>

      <div className="accordion-morph__list">
        {items.map((item, i) => {
          const triggerId = `${uid}-trigger-${i}`;
          const panelId = `${uid}-panel-${i}`;
          const open = startOpen === i;
          return (
            <div className="accordion-morph__row" data-accordion-morph-row key={triggerId}>
              <div className="accordion-morph__goo" data-accordion-morph-goo aria-hidden="true">
                <div className="accordion-morph__goo-pill" data-accordion-morph-goo-pill="" />
                <div className="accordion-morph__goo-panel" data-accordion-morph-goo-panel="" />
              </div>
              <button
                type="button"
                className="accordion-morph__trigger"
                data-accordion-morph-trigger
                id={triggerId}
                aria-controls={panelId}
                aria-expanded={open}
              >
                <span className="accordion-morph__index">{String(i + 1).padStart(2, '0')}</span>
                <span className="accordion-morph__question">{item.question}</span>
                <PlusIcon />
              </button>
              <div
                className="accordion-morph__panel"
                data-accordion-morph-panel
                id={panelId}
                role="region"
                aria-labelledby={triggerId}
                aria-hidden={!open}
              >
                <div className="accordion-morph__panel-inner" data-accordion-morph-inner="">
                  <p className="accordion-morph__answer" data-accordion-morph-answer>
                    {item.answer}
                  </p>
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Webflow

Webflowaccordion-morph

Pega en el Designer como application/json, luego convierte a Component (Atom / Accordion Morph) 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">
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/webflow.css">

Custom Code → Footer (before </body>):

<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/CustomEase.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
<script src="https://atom-web-ds.vercel.app/v1/animations.js"></script>
<script>window.__atomMotionCleanup = AtomMotion.initAll();</script>

DOM contract — no quites estos atributos

  • data-accordion-morph
  • data-accordion-morph-row
  • data-accordion-morph-trigger
  • data-accordion-morph-panel
  • data-accordion-morph-inner
  • data-accordion-morph-answer
  • data-accordion-morph-icon
  • data-accordion-morph-goo
  • data-accordion-morph-goo-pill
  • data-accordion-morph-goo-panel
  • data-accordion-morph-filter

Anatomía: .accordion-morph__goo-pill.accordion-morph__goo-panel.accordion-morph__panel-inner.accordion-morph__filter-svg

Unsupported (no silencioso)

  • :focus-visible on .accordion-morph__trigger:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .accordion-morph__goo-pill, .accordion-morph__goo-panelcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .accordion-morph__trigger:focus-visiblecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • --focus-ring-color on :roottoken not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste
  • --focus-ring-width on :roottoken not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste

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

On this page

Detalles

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

Componente

AccordionMorph

Source

components-react / cssDisponible via MCP: atom_uikit_source("accordion-morph")