Copied!
Band

Sliding column accordion

A row of tall coloured columns labelled on their sides; hover, click or focus one and the whole row slides as it widens and a panel fades in beside its label.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla 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 "Sliding column accordion" for my site using the Enrichr Library element at https://enrichr.dev/elements/sliding-column-accordion.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/sliding-column-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:
- labels: Capture, Verify, Rebuild, Motion, Type, Colour, Layout, Scroll, Pointer, Library, Ship
- texts: Open a page in a real browser and keep what it really does: the layout after scripts run, the motion that plays and the colours that land.|Check every claim against the live page. If an animation never fires, it does not make the list.|Turn what you found into plain HTML, CSS and JavaScript that you can read, change and keep.|Timing, easing and order matter more than distance. Small moves that land on time feel bigger than large ones.|Pick a scale and stick to it. Big headlines, calm body text and generous line spacing do most of the work.|One loud accent, a few quiet greens and plenty of room. Contrast first, mood second.|Columns, rhythm and white space. A grid that bends a little reads as made by hand.|Let the reader drive. Effects follow the scroll, so nothing plays while nobody is looking.|Hover is a hint, not a requirement. Everything that reacts to the mouse also works by touch and keyboard.|Small parts, each one tested, each one free of frameworks and ready to paste.|Fast pages, real text and motion that stops when asked. Publish, then start the next one.
- colours: #e6ff55, #e54c1e, #fbf8f1, #1a7a4c, #4b1a9a, #005236, #d2eb3c, #126e45, #085e3d, #003d28, #0c1e18
- open: 1
- trigger: hover
- grow: 14
- duration: 0.9
- height: 600
- dark: #0c1e18
- light: #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/sliding-column-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-sca">
  <div class="ef-sca__stack"></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-sca { --ef-sca-h: 600px; position: relative; width: 100%; box-sizing: border-box; overflow: hidden; border-radius: 1rem; background: #0c1e18; font-family: "Parkinsans", "Space Grotesk", sans-serif; container-type: inline-size; }
.ef-sca *, .ef-sca *::before, .ef-sca *::after { box-sizing: border-box; }
.ef-sca__stack { display: flex; flex-direction: column; width: 100%; height: var(--ef-sca-h); overflow: hidden; }
.ef-sca__col { position: relative; flex: 1 1 0; min-width: 0; min-height: 50px; overflow: hidden; cursor: pointer; color: var(--ef-sca-fg); background: var(--ef-sca-bg); border-top: 1px solid color-mix(in srgb, var(--ef-sca-bg) 86%, #000); }
.ef-sca__col:first-child { border-top: 0; }
.ef-sca__tab { position: absolute; left: 0; top: 0; z-index: 2; display: flex; flex-direction: row; justify-content: space-between; align-items: center; width: 100%; height: 50px; margin: 0; padding: 0 16px; border: 0; border-radius: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.ef-sca__tab:focus-visible { outline: 3px solid currentColor; outline-offset: -6px; }
.ef-sca__label { display: block; font-size: 18px; line-height: 1; letter-spacing: 0.025em; text-transform: uppercase; white-space: nowrap; }
.ef-sca__num { display: block; font-family: "Instrument Sans", system-ui, sans-serif; font-size: 20px; font-weight: 700; line-height: 1; }
.ef-sca__panel { position: absolute; left: 0; top: 50px; bottom: 0; width: 100%; height: calc(100% - 50px); padding: 24px; display: flex; flex-direction: column; justify-content: space-between; opacity: 0; visibility: hidden; z-index: 0; pointer-events: none; }
.ef-sca .ef-sca__title { margin: 0; font-size: 30px; line-height: 0.9; letter-spacing: -0.025em; color: inherit; opacity: 1; text-transform: none; }
.ef-sca .ef-sca__text { margin: 0; max-width: 280px; font-family: "Instrument Sans", system-ui, sans-serif; font-size: 12px; font-weight: 400; line-height: 1.375; color: inherit; opacity: 0.8; }
.ef-sca__col.is-dark .ef-sca__text { opacity: 0.9; }
@container (min-width: 640px) {
  .ef-sca__stack { flex-direction: row; }
  .ef-sca__col { min-width: 50px; min-height: 0; border-top: 0; border-right: 1px solid color-mix(in srgb, var(--ef-sca-bg) 86%, #000); }
  .ef-sca__col:last-child { border-right: 0; }
  .ef-sca__tab { flex-direction: column; width: 60px; height: 100%; padding: 32px 0; text-align: center; }
  .ef-sca__label { writing-mode: vertical-rl; text-orientation: mixed; font-size: 30px; }
  .ef-sca__num { font-size: 24px; }
  .ef-sca__panel { left: 60px; top: 0; height: 100%; width: 100%; max-width: 480px; padding: 32px; }
  .ef-sca .ef-sca__title { font-size: 44px; }
  .ef-sca .ef-sca__text { max-width: 380px; font-size: 14px; line-height: 1.5; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Sliding column accordion: a row of tall coloured columns, each with a label set on its side in a heavier weight than the
// one before. Hovering, clicking or focusing a column widens it to 14 times the width of the others while they all share
// the rest, a flex-grow tween of 0.9 s on a power3 out curve, so the whole row slides at once. The open column's panel
// fades in and slides 30 px to the right after 0.2 s (0.6 s, power2 out); the panel it replaces fades and slides back out
// in 0.3 s (power2 in). Below 640 px wide the columns stack and open downwards. Reduced motion switches columns instantly.
// Each column's label is a real button with aria-expanded; arrow keys, Home and End move between them.
// mount(root, { labels (comma separated), texts (panel lines separated by |), colours (comma separated hex, repeating),
//               open (column open at start), trigger ("hover" or "click"), grow (open width as a multiple), duration (s),
//               height (px), dark (text on light columns), light (text on dark columns) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-sca");
  var stack = root.querySelector(".ef-sca__stack");
  var opts = {}, cols = [], active = 0, tweens = [], raf = 0, destroyed = false, offs = [], uid = "ef-sca-" + Math.random().toString(36).slice(2, 8);

  // GSAP eases: power2 = cubic, power3 = quartic
  function pIn(n) { return function (p) { return Math.pow(p, n); }; }
  function pOut(n) { return function (p) { return 1 - Math.pow(1 - p, n); }; }
  var P3O = pOut(4), P2O = pOut(3), P2I = pIn(3);
  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function lum(h) {
    var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); if (!m) return 0;
    var v = parseInt(m[1], 16), c = [v >> 16, (v >> 8) & 255, v & 255].map(function (x) { x /= 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); });
    return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
  }

  // Tweens keyed by target and property. Like GSAP, a tween reads its start value when it starts (after any delay), and
  // the newest tween on a key takes over from an older one at that moment.
  function tween(key, dur, delay, ease, get, set, to) {
    var t = { key: key, s: performance.now() + delay * 1000, d: dur * 1000, e: ease, get: get, set: set, to: to, from: null };
    tweens.push(t); kick();
  }
  function kick() { if (!raf && !destroyed) raf = requestAnimationFrame(tick); }
  function tick() {
    raf = 0;
    var n = performance.now();
    for (var i = 0; i < tweens.length; i++) {
      var t = tweens[i];
      if (n < t.s) continue;
      if (t.from === null) {
        for (var j = tweens.length - 1; j >= 0; j--) if (j !== i && tweens[j].key === t.key && tweens[j].from !== null && tweens[j].s <= t.s) { tweens.splice(j, 1); if (j < i) i--; }
        t.from = t.get();
      }
      var p = t.d <= 0 ? 1 : Math.min(1, (n - t.s) / t.d), e = t.e(p);
      t.set(t.from.map(function (f, k) { return f + (t.to[k] - f) * e; }));
      if (p >= 1) { tweens.splice(i, 1); i--; }
    }
    if (tweens.length) kick();
  }

  function setPanel(c, v) {
    c.panel.style.opacity = v[0]; c.panel.style.visibility = v[0] > 0 ? "visible" : "hidden";
    c.panel.style.transform = v[1] ? "translateX(" + v[1].toFixed(2) + "px)" : "";
    c.alpha = v[0]; c.x = v[1];
  }
  function setGrow(c, v) { c.grow = v[0]; c.el.style.flexGrow = v[0].toFixed(4); }

  function activate(index, instant) {
    if (index === active && !instant) return;
    var d = (reduce || instant) ? 0 : opts.duration;
    cols.forEach(function (c, i) {
      var on = i === index;
      tween("g" + i, d, 0, P3O, function () { return [c.grow]; }, function (v) { setGrow(c, v); }, [on ? opts.grow : 1]);
      if (on) tween("p" + i, (reduce || instant) ? 0 : opts.duration * 2 / 3, (reduce || instant) ? 0 : 0.2, P2O, function () { return [c.alpha, c.x]; }, function (v) { setPanel(c, v); }, [1, 0]);
      else tween("p" + i, (reduce || instant) ? 0 : opts.duration / 3, 0, P2I, function () { return [c.alpha, c.x]; }, function (v) { setPanel(c, v); }, [0, -30]);
      c.btn.setAttribute("aria-expanded", on ? "true" : "false");
    });
    active = index;
  }

  function on(t, type, f) { t.addEventListener(type, f); offs.push(function () { t.removeEventListener(type, f); }); }
  function build() {
    offs.forEach(function (f) { f(); }); offs = [];
    tweens = []; stack.textContent = ""; cols = [];
    var labels = String(opts.labels).split(",").map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 16);
    if (!labels.length) labels = ["One", "Two", "Three"];
    var texts = String(opts.texts).split("|").map(function (s) { return s.trim(); }).filter(Boolean);
    var colours = String(opts.colours).split(",").map(function (s) { return s.trim(); }).filter(function (s) { return /^#?[0-9a-f]{6}$/i.test(s); }).map(function (s) { return s[0] === "#" ? s : "#" + s; });
    if (!colours.length) colours = ["#e6ff55", "#005236"];
    var n = labels.length;
    labels.forEach(function (label, i) {
      var bg = colours[i % colours.length], dark = lum(bg) < 0.18;
      var weight = Math.round((400 + (n > 1 ? i / (n - 1) : 0) * 400) / 100) * 100;
      var col = document.createElement("div"); col.className = "ef-sca__col" + (dark ? " is-dark" : "");
      col.style.setProperty("--ef-sca-bg", bg);
      col.style.setProperty("--ef-sca-fg", dark ? opts.light : opts.dark);
      var btn = document.createElement("button"); btn.type = "button"; btn.className = "ef-sca__tab"; btn.id = uid + "-t" + i;
      btn.setAttribute("aria-controls", uid + "-p" + i); btn.setAttribute("aria-expanded", "false");
      var lab = document.createElement("span"); lab.className = "ef-sca__label"; lab.textContent = label; lab.style.fontWeight = weight;
      var nr = document.createElement("span"); nr.className = "ef-sca__num"; nr.textContent = String(i + 1); nr.setAttribute("aria-hidden", "true");
      btn.appendChild(lab); btn.appendChild(nr);
      var panel = document.createElement("div"); panel.className = "ef-sca__panel"; panel.id = uid + "-p" + i;
      panel.setAttribute("role", "region"); panel.setAttribute("aria-labelledby", btn.id);
      var h = document.createElement("h3"); h.className = "ef-sca__title"; h.textContent = label; h.style.fontWeight = weight;
      var p = document.createElement("p"); p.className = "ef-sca__text"; p.textContent = texts.length ? texts[i % texts.length] : "";
      panel.appendChild(h); panel.appendChild(p);
      col.appendChild(btn); col.appendChild(panel); stack.appendChild(col);
      var c = { el: col, btn: btn, panel: panel, grow: 1, alpha: 0, x: -30 };
      setGrow(c, [1]); setPanel(c, [0, -30]);
      cols.push(c);
      on(col, "mouseenter", function () { if (opts.trigger !== "click") activate(i); });
      on(col, "click", function () { activate(i); });
      on(btn, "focus", function () { activate(i); });
      on(btn, "keydown", function (e) {
        var k = e.key, to = -1;
        if (k === "ArrowRight" || k === "ArrowDown") to = (i + 1) % n;
        else if (k === "ArrowLeft" || k === "ArrowUp") to = (i - 1 + n) % n;
        else if (k === "Home") to = 0;
        else if (k === "End") to = n - 1;
        if (to >= 0) { e.preventDefault(); cols[to].btn.focus(); }
      });
    });
    active = -1;
    activate(Math.min(n - 1, Math.max(0, Math.round(opts.open) - 1)), true);
  }
  function update(next) {
    opts = Object.assign({
      labels: "Capture, Verify, Rebuild, Motion, Type, Colour, Layout, Scroll, Pointer, Library, Ship",
      texts: "Open a page in a real browser and keep what it really does: the layout after scripts run, the motion that plays and the colours that land.|Check every claim against the live page. If an animation never fires, it does not make the list.|Turn what you found into plain HTML, CSS and JavaScript that you can read, change and keep.|Timing, easing and order matter more than distance. Small moves that land on time feel bigger than large ones.|Pick a scale and stick to it. Big headlines, calm body text and generous line spacing do most of the work.|One loud accent, a few quiet greens and plenty of room. Contrast first, mood second.|Columns, rhythm and white space. A grid that bends a little reads as made by hand.|Let the reader drive. Effects follow the scroll, so nothing plays while nobody is looking.|Hover is a hint, not a requirement. Everything that reacts to the mouse also works by touch and keyboard.|Small parts, each one tested, each one free of frameworks and ready to paste.|Fast pages, real text and motion that stops when asked. Publish, then start the next one.",
      colours: "#e6ff55, #e54c1e, #fbf8f1, #1a7a4c, #4b1a9a, #005236, #d2eb3c, #126e45, #085e3d, #003d28, #0c1e18",
      open: 1, trigger: "hover", grow: 14, duration: 0.9, height: 600, dark: "#0c1e18", light: "#fbf8f1"
    }, opts, next || {});
    opts.open = num(opts.open, 1); opts.grow = Math.max(1, num(opts.grow, 14)); opts.duration = Math.max(0.05, num(opts.duration, 0.9));
    opts.height = Math.max(240, num(opts.height, 600));
    var stacked = box.clientWidth < 640, n = String(opts.labels).split(",").filter(function (s) { return s.trim(); }).length;
    box.style.setProperty("--ef-sca-h", (stacked ? Math.max(opts.height, 50 * Math.max(0, n - 1) + 320) : opts.height) + "px");
    var key = [opts.labels, opts.texts, opts.colours, opts.dark, opts.light, opts.open].join("§");
    if (key !== update.key) { update.key = key; build(); }
    else if (active >= 0) setGrow(cols[active], [opts.grow]);
  }

  update(o);
  var ro = null, lastW = box.clientWidth;
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (destroyed) return; var w = box.clientWidth; if ((w < 640) !== (lastW < 640)) update({}); lastW = w; }); ro.observe(box); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf); tweens = [];
      offs.forEach(function (f) { f(); }); offs = [];
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"labels":"Capture, Verify, Rebuild, Motion, Type, Colour, Layout, Scroll, Pointer, Library, Ship","texts":"Open a page in a real browser and keep what it really does: the layout after scripts run, the motion that plays and the colours that land.|Check every claim against the live page. If an animation never fires, it does not make the list.|Turn what you found into plain HTML, CSS and JavaScript that you can read, change and keep.|Timing, easing and order matter more than distance. Small moves that land on time feel bigger than large ones.|Pick a scale and stick to it. Big headlines, calm body text and generous line spacing do most of the work.|One loud accent, a few quiet greens and plenty of room. Contrast first, mood second.|Columns, rhythm and white space. A grid that bends a little reads as made by hand.|Let the reader drive. Effects follow the scroll, so nothing plays while nobody is looking.|Hover is a hint, not a requirement. Everything that reacts to the mouse also works by touch and keyboard.|Small parts, each one tested, each one free of frameworks and ready to paste.|Fast pages, real text and motion that stops when asked. Publish, then start the next one.","colours":"#e6ff55, #e54c1e, #fbf8f1, #1a7a4c, #4b1a9a, #005236, #d2eb3c, #126e45, #085e3d, #003d28, #0c1e18","open":1,"trigger":"hover","grow":14,"duration":0.9,"height":600,"dark":"#0c1e18","light":"#fbf8f1"});

How it works

The columns are flex items that share one row; each starts with a flex-grow of 1 and a minimum width of 50 px. Opening a column tweens its flex-grow to 14 and every other column back to 1 over 0.9 seconds on a power3 out curve, all at the same time, so the row redistributes in one fluid slide instead of one panel pushing the next. The open column's panel waits 0.2 seconds, then fades in and slides 30 px into place over 0.6 seconds on a power2 out curve; the panel that closes fades and slides 30 px back over 0.3 seconds on a power2 in curve, and is hidden once it reaches zero, like GSAP's autoAlpha. Each label is set on its side with writing-mode, in a weight that steps from 400 for the first column to 800 for the last. Text colour is picked per column from its brightness. Below 640 px wide the columns stack and open downwards with the labels across.

Accessibility and performance

Each label is a real button with aria-expanded and aria-controls, and each panel is a region named by its button. Focusing a button opens its column, so keyboard users see the same thing as mouse users; the arrow keys, Home and End move between the buttons. Closed panels are visibility: hidden, which keeps their text out of the tab order and the reading order. With prefers-reduced-motion columns switch instantly.

Animating flex-grow changes layout each frame, but only for this one row of a dozen boxes with little inside them, which is cheap. Panels only change opacity and transform. Nothing runs between interactions.

SectionBand
TechniquesPointer interaction, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired bySliding weight columns specimen study 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.

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. Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

FAQ

Why flex-grow and not width?

Tweening flex-grow lets the browser share out the space: the open column takes 14 parts and every other column one, whatever the container's width, so the row always fills exactly and resizes cleanly.

How do I change the panel text?

Type it in Panel text with a | between columns. The first piece goes with the first column, and the list repeats if there are more columns than pieces.

Can it open on click only?

Yes. Set Opens on to Click only; hovering then does nothing and a click or keyboard focus opens a column.

How is this different from the editorial accordion?

The editorial accordion opens rows downward and pushes content below it. This one keeps a fixed height and slides every column sideways at once, which suits a bold band across a page.