Copied!
Text

Editorial accordion

Big numbered rows that open to reveal a short text and a drawn glyph, with a smooth height animation and no measuring.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Editorial accordion" for my site using the Enrichr Library element at https://enrichr.dev/elements/editorial-accordion.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/editorial-accordion.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- items: Explore: Look closer at the movement, rhythm and structure behind a page you remember. | Experiment: Change the timing, push the shape, and make a small interaction feel like your own. | Build: Turn the idea into a component you can reuse, then sweat the details.
- single: 1
- open: 1
- duration: 650
- accent: #e6ff55
- background: #0c1e18
- ink: #fbf8f1

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/editorial-accordion.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-eda">
  <div class="ef-eda__list"></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-eda { --ef-eda-bg: #0c1e18; --ef-eda-acc: #e6ff55; --ef-eda-ink: #fbf8f1; --ef-eda-d: 650ms; --ef-eda-e: cubic-bezier(0.65, 0, 0.35, 1); width: 100%; box-sizing: border-box; padding: 0.5rem 1.4rem; border-radius: 1rem; background: var(--ef-eda-bg); color: var(--ef-eda-ink); font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; container-type: inline-size; }
.ef-eda__item { border-top: 1px solid color-mix(in srgb, var(--ef-eda-ink) 22%, transparent); }
.ef-eda__item:last-child { border-bottom: 1px solid color-mix(in srgb, var(--ef-eda-ink) 22%, transparent); }
.ef-eda__h { margin: 0; font: inherit; }
.ef-eda__btn { display: flex; align-items: center; gap: 1.2rem; width: 100%; padding: 1.1rem 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: color 300ms; }
.ef-eda__n { flex: none; font: 600 0.72rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: 0.1em; opacity: 0.7; }
.ef-eda__t { font-size: clamp(1.8rem, 8cqi, 4.6rem); font-weight: 800; line-height: 1; letter-spacing: -0.05em; }
.ef-eda__ico { position: relative; flex: none; margin-left: auto; width: 1.6rem; height: 1.6rem; }
.ef-eda__ico::before, .ef-eda__ico::after { content: ""; position: absolute; left: 0; top: 50%; width: 100%; height: 2px; margin-top: -1px; background: currentColor; transition: transform var(--ef-eda-d) var(--ef-eda-e); }
.ef-eda__ico::after { transform: rotate(90deg); }
.ef-eda__btn[aria-expanded="true"] { color: var(--ef-eda-acc); }
.ef-eda__btn[aria-expanded="true"] .ef-eda__ico::after { transform: rotate(0deg); }
.ef-eda__btn[aria-expanded="true"] .ef-eda__ico::before { transform: rotate(180deg); }
.ef-eda__btn:hover .ef-eda__t { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 0.12em; }
.ef-eda__btn:focus-visible { outline: 3px solid var(--ef-eda-acc); outline-offset: 4px; border-radius: 4px; }
.ef-eda__panel { display: grid; grid-template-rows: 0fr; visibility: hidden; transition: grid-template-rows var(--ef-eda-d) var(--ef-eda-e), visibility 0s linear var(--ef-eda-d); }
.ef-eda__panel.is-open { grid-template-rows: 1fr; visibility: visible; transition: grid-template-rows var(--ef-eda-d) var(--ef-eda-e), visibility 0s; }
.ef-eda__inner { min-height: 0; overflow: hidden; }
.ef-eda__body { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding: 0 0 1.6rem 2.6rem; opacity: 0; transform: translateY(1.2rem); transition: opacity calc(var(--ef-eda-d) * 0.5) ease, transform calc(var(--ef-eda-d) * 0.7) var(--ef-eda-e); }
.ef-eda__panel.is-open .ef-eda__body { opacity: 1; transform: none; transition-delay: calc(var(--ef-eda-d) * 0.25); }
.ef-eda__p { margin: 0; max-width: 34ch; font-size: clamp(1rem, 2.6cqi, 1.3rem); line-height: 1.5; opacity: 0.85; }
.ef-eda__glyph { flex: none; width: clamp(4rem, 16cqi, 8.5rem); height: auto; color: var(--ef-eda-acc); transform: rotate(-90deg) scale(0.4); transition: transform var(--ef-eda-d) cubic-bezier(0.34, 1.56, 0.64, 1); }
.ef-eda__panel.is-open .ef-eda__glyph { transform: none; transition-delay: calc(var(--ef-eda-d) * 0.2); }
@container (max-width: 30rem) { .ef-eda__body { padding-left: 0; } .ef-eda__glyph { width: 3.5rem; } }
@media (prefers-reduced-motion: reduce) { .ef-eda { --ef-eda-d: 0ms; } .ef-eda__body, .ef-eda__glyph { transform: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Editorial accordion: big numbered rows that open to reveal a short text and a drawn glyph. The panel height animates with
// the CSS grid-template-rows 0fr to 1fr trick, so there is no height measuring, and the content slides up a beat later.
// Closed panels are visibility: hidden, which takes their content out of the tab order and the accessibility tree.
// Headers are real buttons with aria-expanded and aria-controls; arrow keys, Home and End move between them.
// mount(root, { items ("Title: text" pairs separated by |), single ("1" = one open at a time, "0" = many), open (row to open first, 0 = none),
//               duration (ms), accent, background, ink })
function mount(root, o) {
  var list = root.querySelector(".ef-eda__list");
  var box = root.querySelector(".ef-eda");
  var uid = "ef-eda-" + Math.random().toString(36).slice(2, 8);
  var opts = {}, rows = [], offs = [];
  var NS = "http://www.w3.org/2000/svg";
  var GLYPHS = [
    '<g stroke-width="11" stroke-linecap="round"><path d="M50 8V92M8 50H92M20 20L80 80M80 20L20 80"/></g>',
    '<circle cx="50" cy="50" r="40" fill="none" stroke-width="8"/><path d="M50 10a40 40 0 0 1 0 80z" stroke="none" fill="currentColor"/>',
    '<path d="M22 78L78 22M36 22h42v42" fill="none" stroke-width="11" stroke-linecap="round" stroke-linejoin="round"/>',
    '<circle cx="50" cy="50" r="40" fill="none" stroke-width="7"/><circle cx="50" cy="50" r="22" fill="none" stroke-width="7"/><circle cx="50" cy="50" r="6" stroke="none" fill="currentColor"/>',
    '<path d="M50 6L94 50L50 94L6 50Z" fill="none" stroke-width="8" stroke-linejoin="round"/><path d="M50 30L70 50L50 70L30 50Z" stroke="none" fill="currentColor"/>'
  ];
  function glyph(i) {
    var svg = document.createElementNS(NS, "svg");
    svg.setAttribute("viewBox", "0 0 100 100"); svg.setAttribute("class", "ef-eda__glyph");
    svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false");
    svg.setAttribute("stroke", "currentColor"); svg.setAttribute("fill", "none");
    svg.innerHTML = GLYPHS[i % GLYPHS.length];
    return svg;
  }
  function parse(str) {
    return String(str).split("|").map(function (c) {
      var k = c.indexOf(":");
      return k < 0 ? { t: c.trim(), b: "" } : { t: c.slice(0, k).trim(), b: c.slice(k + 1).trim() };
    }).filter(function (i) { return i.t; }).slice(0, 8);
  }
  function set(row, open) {
    row.btn.setAttribute("aria-expanded", open ? "true" : "false");
    row.panel.classList.toggle("is-open", open);
  }
  function toggle(i) {
    var open = rows[i].btn.getAttribute("aria-expanded") !== "true";
    if (open && String(opts.single) === "1") rows.forEach(function (r, j) { if (j !== i) set(r, false); });
    set(rows[i], open);
  }
  function build() {
    list.textContent = "";
    rows = [];
    var items = parse(opts.items);
    if (!items.length) items = [{ t: "Item", b: "Add items as Title: text, separated by |" }];
    items.forEach(function (it, i) {
      var sec = document.createElement("section"); sec.className = "ef-eda__item";
      var h = document.createElement("h3"); h.className = "ef-eda__h";
      var btn = document.createElement("button"); btn.type = "button"; btn.className = "ef-eda__btn";
      btn.id = uid + "-b" + i; btn.setAttribute("aria-controls", uid + "-p" + i);
      var n = document.createElement("span"); n.className = "ef-eda__n"; n.setAttribute("aria-hidden", "true"); n.textContent = String(i + 1).padStart(2, "0");
      var t = document.createElement("span"); t.className = "ef-eda__t"; t.textContent = it.t;
      var ico = document.createElement("span"); ico.className = "ef-eda__ico"; ico.setAttribute("aria-hidden", "true");
      btn.appendChild(n); btn.appendChild(t); btn.appendChild(ico);
      h.appendChild(btn);
      var panel = document.createElement("div"); panel.className = "ef-eda__panel"; panel.id = uid + "-p" + i;
      panel.setAttribute("role", "region"); panel.setAttribute("aria-labelledby", btn.id);
      var inner = document.createElement("div"); inner.className = "ef-eda__inner";
      var body = document.createElement("div"); body.className = "ef-eda__body";
      var p = document.createElement("p"); p.className = "ef-eda__p"; p.textContent = it.b;
      body.appendChild(p); body.appendChild(glyph(i));
      inner.appendChild(body); panel.appendChild(inner);
      sec.appendChild(h); sec.appendChild(panel);
      list.appendChild(sec);
      var row = { btn: btn, panel: panel };
      rows.push(row);
      btn.addEventListener("click", function () { toggle(i); });
      set(row, Number(opts.open) === i + 1);
    });
  }
  function onKey(e) {
    var i = rows.map(function (r) { return r.btn; }).indexOf(document.activeElement);
    if (i < 0) return;
    var to = e.key === "ArrowDown" ? (i + 1) % rows.length : e.key === "ArrowUp" ? (i - 1 + rows.length) % rows.length : e.key === "Home" ? 0 : e.key === "End" ? rows.length - 1 : -1;
    if (to < 0) return;
    e.preventDefault();
    rows[to].btn.focus();
  }
  list.addEventListener("keydown", onKey);
  offs.push(function () { list.removeEventListener("keydown", onKey); });

  function update(next) {
    opts = Object.assign({ items: "Explore: Look closer at the movement, rhythm and structure behind a page you remember. | Experiment: Change the timing, push the shape, and make a small interaction feel like your own. | Build: Turn the idea into a component you can reuse, then sweat the details.", single: "1", open: 1, duration: 650, accent: "#e6ff55", background: "#0c1e18", ink: "#fbf8f1" }, opts, next || {});
    box.style.setProperty("--ef-eda-d", Math.max(0, Number(opts.duration) || 0) + "ms");
    box.style.setProperty("--ef-eda-acc", opts.accent);
    box.style.setProperty("--ef-eda-bg", opts.background);
    box.style.setProperty("--ef-eda-ink", opts.ink);
    build();
  }
  update(o);
  return { update: update, destroy: function () { offs.forEach(function (f) { f(); }); offs = []; } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Explore: Look closer at the movement, rhythm and structure behind a page you remember. | Experiment: Change the timing, push the shape, and make a small interaction feel like your own. | Build: Turn the idea into a component you can reuse, then sweat the details.","single":1,"open":1,"duration":650,"accent":"#e6ff55","background":"#0c1e18","ink":"#fbf8f1"});

How it works

Each panel is a one-column CSS grid whose single row animates from 0fr to 1fr, with the content inside set to min-height: 0 and overflow: hidden. The browser animates to the content's natural height without any JavaScript measuring, so it stays right when text wraps or fonts load. The content slides up and fades in a beat after the panel starts opening, and the glyph springs in on an overshooting curve. The plus icon is two bars, one of which rotates flat to make the minus. In one-at-a-time mode opening a row closes the others.

Accessibility and performance

Each header is a real button inside a heading, with aria-expanded and aria-controls, and each panel is a region labelled by its button. Closed panels become visibility: hidden after their transition, so their content leaves the tab order and the accessibility tree. Up and Down arrows, Home and End move between headers. With prefers-reduced-motion rows open instantly.

Only CSS transitions run; the script toggles a class and an attribute. Animating grid rows causes layout inside the accordion while it moves, which is cheap for a handful of rows, and nothing runs at rest.

SectionText
TechniquesReveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGSAP Expanding Editorial Accordion by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Circle reveal introA gate button opens a circle from the click point that grows to reveal the page. Mask shape sectionsThe same image revealed through a circle, a diamond and a band as each section scrolls into view. Clip reveal menuText rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge.

FAQ

Why grid rows instead of animating height?

height: auto cannot be transitioned in most browsers. Animating grid-template-rows from 0fr to 1fr reaches the natural height without measuring, and adapts when the content changes.

Can I put links or forms in a panel?

Yes. Closed panels are visibility: hidden, so their links cannot be focused until the row is opened.

How do I write items that contain commas?

Items are separated by a vertical bar and each item is Title: text, so commas inside the text are fine.