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.
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
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| gooStrength | number | 9 | 4–16 step 1 px | stdDeviation 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). |
| multiple | boolean | `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. | |
| startOpen | number | -1 | -1–20 step 1 index | Indice 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. |
| animated | boolean | `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>
| Clase | Propósito |
|---|---|
accordion-morph | root |
accordion-morph__answer | element |
accordion-morph__answer-line | element |
accordion-morph__filter-svg | element |
accordion-morph__goo | element |
accordion-morph__goo-panel | element |
accordion-morph__goo-pill | element |
accordion-morph__icon | element |
accordion-morph__index | element |
accordion-morph__list | element |
accordion-morph__panel | element |
accordion-morph__panel-inner | element |
accordion-morph__question | element |
accordion-morph__row | element |
accordion-morph__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 | bg | #ffffff | card |
| all | fg | #0a0a0a | card.foreground |
| all | border | #e5e5e5 | border |
| all | hover-bg | #f5f5f5 | accent |
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
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
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-morphdata-accordion-morph-rowdata-accordion-morph-triggerdata-accordion-morph-paneldata-accordion-morph-innerdata-accordion-morph-answerdata-accordion-morph-icondata-accordion-morph-goodata-accordion-morph-goo-pilldata-accordion-morph-goo-paneldata-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-visibleon.accordion-morph__trigger:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.accordion-morph__goo-pill, .accordion-morph__goo-panel— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.accordion-morph__trigger:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)--focus-ring-coloron:root— token not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste--focus-ring-widthon:root— token not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste
Tras pegar: Create component → nombre Atom / Accordion Morph → Publish.