ui-047

Animated Tabs

Free

Animated tabs: the indicator slides and stretches between tabs, panels change directionally and the container eases between heights.

intermediate
5 more details
tabskeyboard-navigationstate-transitionhover-effectmicro-interaction

This element reads better at your own screen size than in the frame below:

Released

About this component

A tabbed panel where the indicator does the work. It slides and stretches between tabs, measured from each tab's own box rather than guessed from a fixed width, so labels of different lengths all get an indicator the exact width of the tab.

Read the full effect overview

The panels then follow the indicator rather than cutting: the outgoing one leaves in the direction you are travelling, the incoming one arrives from the other side a frame later, and the panel box tweens between the two heights so nothing below the component jumps. Two looks ship from one attribute: an underline, and a filled segment at tab scale.

Animated Tabs - UI component preview

What's included

10 items
  • Indicator slides and stretches between tabs, measured from each tab's own box so any label length lands exactly
  • Directional panel change: the outgoing panel exits the way you are travelling, the incoming one arrives from the other side
  • The panel box tweens between panel heights, so a shorter panel never snaps the page below it
  • A soft hover pill follows the pointer across the tabs, gated to fine pointers so a tap cannot pin it open
  • Two looks from one attribute: data-style="underline" and data-style="pill"
  • Full WAI-ARIA tabs pattern with automatic activation: roving tabindex, arrow keys that wrap, Home and End
  • Re-measures on resize, on fonts.ready and on a theme change, so an indicator is never left offset
  • Overflowing tab lists scroll sideways with a faded edge, and the selected tab scrolls itself into view
  • Optional data-tabs-hash deep linking that updates the URL without scrolling the page
  • Emits a tabchange event carrying the new and previous index

Perfect for

5 use cases
  • Product, account and project detail pages where one record has several sections
  • Settings and preference screens that would otherwise be one long scroll
  • Dashboard cards that need to hold two or three views in the same footprint
  • Documentation and pricing pages that switch between comparable blocks of content
  • Any tab strip that has to survive a phone, where the labels no longer fit in a row

How it works

3 sections

The indicator's position and width are measured from each tab's own box against the tab list, so labels of different lengths all get an indicator exactly their width, and a page zoom cannot throw the measurement off. The same pass records every panel's height, including the ones not on screen, so the box can ease between heights instead of jumping.

A change runs three movements on one beat: the indicator leads, the panel box eases to the new height, and the panels cross with a short slide whose direction follows the direction of travel. The outgoing panel is only hidden once it has fully left, so a transparent panel is never left covering the live one.

The hover pill is reserved for fine pointers, and under reduced motion every movement completes at once; the device conditions are complementary pairs, so a coarse-pointer device always matches something and the component always initialises.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Animated Tabs &mdash; GSAP Vault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar; not part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__label { color: var(--ink-3); }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: 6px; border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    /* One row on a phone: the prompt goes so both control groups stay on screen. */
    @media (max-width: 620px) {
      .showcase-toolbar__prompt, .showcase-toolbar__label { display: none; }
      .showcase-toolbar { justify-content: flex-end; }
    }
  </style>
</head>
<body data-variant="light">

  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">Click a tab, or use the arrow keys, and watch the indicator lead the panel.</span>
    <span class="showcase-toolbar__label">Style</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Indicator style">
      <button class="style-btn" type="button" data-style="underline" aria-pressed="true">Underline</button>
      <button class="style-btn" type="button" data-style="pill" aria-pressed="false">Pill</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="true">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="false">Dark</button>
    </div>
  </header>

  <main class="stage">
    <div class="stage__inner">

      <section class="tabs" id="tabs" data-tabs data-style="underline" data-tabs-hash>
        <div class="tabs__strip">
          <div class="tabs__scroller">
            <div class="tabs__list" role="tablist" aria-label="Release sections">
              <span class="tabs__hover" aria-hidden="true"></span>
              <span class="tabs__indicator" aria-hidden="true"></span>

              <button class="tabs__tab" type="button" role="tab" id="tab-overview" data-tab="overview"
                      aria-controls="panel-overview" aria-selected="true" tabindex="0">Overview</button>

              <button class="tabs__tab" type="button" role="tab" id="tab-activity" data-tab="activity"
                      aria-controls="panel-activity" aria-selected="false" tabindex="-1">Activity<span class="tabs__badge">3<span class="tabs__sr"> new updates</span></span></button>

              <button class="tabs__tab" type="button" role="tab" id="tab-files" data-tab="files"
                      aria-controls="panel-files" aria-selected="false" tabindex="-1">Files</button>

              <button class="tabs__tab" type="button" role="tab" id="tab-people" data-tab="people"
                      aria-controls="panel-people" aria-selected="false" tabindex="-1">People</button>

              <button class="tabs__tab" type="button" role="tab" id="tab-settings" data-tab="settings"
                      aria-controls="panel-settings" aria-selected="false" tabindex="-1">Settings</button>
            </div>
          </div>
        </div>

        <div class="tabs__panels">

          <div class="tabs__panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0">
            <p class="tabs__lead">Everything for the September release lives here: the brief, the files it produced, and the people reviewing them. Changes are visible to the whole team as soon as they are saved.</p>
            <dl class="tabs__facts">
              <div><dt>Status</dt><dd>In review</dd></div>
              <div><dt>Owner</dt><dd>Priya Raman</dd></div>
              <div><dt>Created</dt><dd>12 August 2026</dd></div>
              <div><dt>Visibility</dt><dd>Team</dd></div>
            </dl>
          </div>

          <div class="tabs__panel" role="tabpanel" id="panel-activity" aria-labelledby="tab-activity" tabindex="0" hidden>
            <p class="tabs__lead">Three updates since you last opened this. Comment threads stay attached to the file version they were raised on, so resolving one does not hide the history behind it.</p>
            <dl class="tabs__facts">
              <div><dt>Last update</dt><dd>2 hours ago</dd></div>
              <div><dt>Open threads</dt><dd>3</dd></div>
              <div><dt>Resolved this week</dt><dd>11</dd></div>
              <div><dt>Digest</dt><dd>Daily, 09:00</dd></div>
            </dl>
          </div>

          <div class="tabs__panel" role="tabpanel" id="panel-files" aria-labelledby="tab-files" tabindex="0" hidden>
            <p class="tabs__lead">Twelve files, six of them changed in the last week. Dropping a file with a name that already exists adds a version rather than replacing what is there.</p>
            <dl class="tabs__facts">
              <div><dt>Files</dt><dd>12</dd></div>
              <div><dt>Storage used</dt><dd>248 MB</dd></div>
              <div><dt>Latest upload</dt><dd>brief-v4.pdf</dd></div>
              <div><dt>Version history</dt><dd>90 days</dd></div>
            </dl>
          </div>

          <div class="tabs__panel" role="tabpanel" id="panel-people" aria-labelledby="tab-people" tabindex="0" hidden>
            <p class="tabs__lead">Four reviewers and two editors. Reviewers can comment and approve; editors can also upload new versions and change who else has access.</p>
            <dl class="tabs__facts">
              <div><dt>Editors</dt><dd>2</dd></div>
              <div><dt>Reviewers</dt><dd>4</dd></div>
              <div><dt>Pending invites</dt><dd>1</dd></div>
              <div><dt>Approvals needed</dt><dd>2 of 4</dd></div>
            </dl>
          </div>

          <div class="tabs__panel" role="tabpanel" id="panel-settings" aria-labelledby="tab-settings" tabindex="0" hidden>
            <p class="tabs__lead">Anyone with the link can comment. Changing the visibility applies to every file in this section, including versions uploaded before the change.</p>
            <dl class="tabs__facts">
              <div><dt>Visibility</dt><dd>Anyone with link</dd></div>
              <div><dt>Comments</dt><dd>Enabled</dd></div>
              <div><dt>Notifications</dt><dd>Daily digest</dd></div>
              <div><dt>Region</dt><dd>EU (Frankfurt)</dd></div>
            </dl>
          </div>

        </div>
      </section>

    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="assets/script.js"></script>

  <script>
    // Demo-only: the toolbar. Switches data-variant on <body> and data-style on
    // the component, then dispatches a resize so anything measured (the sliding
    // indicator, the panel height) re-measures against the new padding.
    (function () {
      function reMeasure() {
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }

      var variantButtons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var variantNames = variantButtons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        variantButtons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        reMeasure();
      }
      var wantedVariant = new URLSearchParams(location.search).get('variant');
      setVariant(variantNames.indexOf(wantedVariant) > -1 ? wantedVariant : (document.body.getAttribute('data-variant') || variantNames[0]));
      variantButtons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });

      var tabsRoot = document.getElementById('tabs');
      var styleButtons = Array.prototype.slice.call(document.querySelectorAll('.style-btn'));
      var styleNames = styleButtons.map(function (b) { return b.dataset.style; });
      function setStyle(name) {
        tabsRoot.setAttribute('data-style', name);
        styleButtons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.style === name)); });
        reMeasure();
      }
      var wantedStyle = new URLSearchParams(location.search).get('style');
      setStyle(styleNames.indexOf(wantedStyle) > -1 ? wantedStyle : (tabsRoot.getAttribute('data-style') || styleNames[0]));
      styleButtons.forEach(function (b) { b.addEventListener('click', function () { setStyle(b.dataset.style); }); });
    })();
  </script>
</body>
</html>
/**
 * Sliding Indicator Tabs
 *
 * A tab list whose indicator slides and stretches between tabs, and panels that
 * change directionally: the underline leads, the outgoing panel leaves the way
 * you are travelling, the incoming one arrives from the other side a frame
 * later, and the panel box morphs between the two heights so nothing below the
 * component jumps.
 *
 * @plugins none (GSAP core)
 * @techniques hover-effect, click-toggle, keyboard-navigation, micro-interaction, snap
 */

(function () {
    /* Runs the init straight away if the DOM is already parsed (a script
       executed late or deferred, e.g. by Cloudflare Rocket Loader), and waits
       for DOMContentLoaded otherwise. Never a bare DOMContentLoaded listener:
       if the script runs after the event fired, the init silently never does. */
    (function onReady(init) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init, { once: true });
        } else {
            init();
        }
    })(function initSlidingIndicatorTabs() {
        if (typeof gsap === 'undefined') {
            console.warn('[sliding-indicator-tabs] GSAP is required but was not found.');
            return;
        }

        document.documentElement.classList.add('has-js');

        /* Every helper and every piece of shared state is declared before first
           use: the demo build turns function declarations into non-hoisted
           consts, so anything relying on hoisting throws only once shipped. */

        var DURATION = {
            indicator: 0.34,   // the underline leads
            height: 0.36,      // the box morphs with it
            out: 0.18,         // the outgoing panel leaves
            in: 0.3,           // the incoming one arrives...
            inDelay: 0.08,     // ...a frame later, which is the whole idea
            hover: 0.24,
            scroll: 0.35
        };
        var SLIDE = 24;        // px the panels travel; short enough to read as one move
        var EASE = 'power3.out';

        var instances = [];

        var isReducedNow = function () {
            return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        };

        /* Panels are absolutely stacked, so a panel that is currently hidden has
           to be measured with the attribute off and visibility off; anything
           else measures zero and the box collapses on the first change. */
        var measurePanels = function (inst) {
            inst.heights = inst.panels.map(function (panel) {
                if (!panel.hasAttribute('hidden')) return panel.offsetHeight;
                panel.style.visibility = 'hidden';
                panel.removeAttribute('hidden');
                var h = panel.offsetHeight;
                panel.setAttribute('hidden', '');
                panel.style.visibility = '';
                return h;
            });
        };

        /* offsetLeft/offsetWidth against the list, never getBoundingClientRect:
           the rect is multiplied by any page zoom (the thumbnail frame sets
           one), which lands the indicator at a multiple of its own offset. The
           indicator has no CSS transform and no CSS left, so x IS the offset. */
        var placeIndicator = function (inst, animate) {
            var tab = inst.tabs[inst.index];
            if (!tab) return;
            var to = { x: tab.offsetLeft, width: tab.offsetWidth };
            if (animate) {
                gsap.to(inst.indicator, Object.assign({
                    duration: DURATION.indicator, ease: EASE, overwrite: 'auto'
                }, to));
            } else {
                gsap.set(inst.indicator, to);
            }
        };

        var setBoxHeight = function (inst, animate) {
            var h = inst.heights[inst.index];
            if (!h) return;
            if (animate) {
                gsap.to(inst.box, { height: h, duration: DURATION.height, ease: EASE, overwrite: 'auto' });
            } else {
                gsap.set(inst.box, { height: h });
            }
        };

        /* The strip fades whichever edge still has tabs behind it, so an
           overflowing list reads as scrollable without a scrollbar. */
        var updateEdges = function (inst) {
            var sc = inst.scroller;
            var host = inst.strip;
            var over = sc.scrollWidth > sc.clientWidth + 1;
            host.classList.toggle('is-overflow', over);
            host.classList.toggle('is-start', over && sc.scrollLeft > 2);
            host.classList.toggle('is-end', over && sc.scrollLeft < sc.scrollWidth - sc.clientWidth - 2);
        };

        var scrollTabIntoView = function (inst, animate) {
            var sc = inst.scroller;
            var tab = inst.tabs[inst.index];
            if (!tab || sc.scrollWidth <= sc.clientWidth + 1) return;
            var pad = 16;
            var target = sc.scrollLeft;
            var left = tab.offsetLeft - pad;
            var right = tab.offsetLeft + tab.offsetWidth + pad;
            if (left < sc.scrollLeft) target = left;
            else if (right > sc.scrollLeft + sc.clientWidth) target = right - sc.clientWidth;
            target = Math.max(0, Math.min(target, sc.scrollWidth - sc.clientWidth));
            if (Math.abs(target - sc.scrollLeft) < 1) return;
            if (animate) {
                gsap.to(sc, { scrollLeft: target, duration: DURATION.scroll, ease: 'power2.out', overwrite: 'auto' });
            } else {
                sc.scrollLeft = target;
            }
        };

        var setHoverPill = function (inst, tab) {
            if (!inst.hover) return;
            var showing = Number(gsap.getProperty(inst.hover, 'opacity')) > 0.01;
            var to = { x: tab.offsetLeft, width: tab.offsetWidth, opacity: 1 };
            if (!showing || inst.reduced) {
                gsap.set(inst.hover, to);
            } else {
                gsap.to(inst.hover, Object.assign({ duration: DURATION.hover, ease: EASE, overwrite: 'auto' }, to));
            }
        };

        var hideHoverPill = function (inst) {
            if (!inst.hover) return;
            gsap.to(inst.hover, { opacity: 0, duration: inst.reduced ? 0 : 0.18, ease: 'power2.out', overwrite: 'auto' });
        };

        var syncHash = function (inst) {
            if (!inst.useHash) return;
            var key = inst.tabs[inst.index].dataset.tab;
            if (!key) return;
            /* replaceState, never `location.hash = …`: assigning the hash makes
               the browser jump to the element with that id. */
            try {
                history.replaceState(null, '', '#' + key);
            } catch (err) { /* file:// and sandboxed frames refuse; the tabs still work */ }
        };

        var activate = function (inst, next, opts) {
            var options = opts || {};
            var count = inst.tabs.length;
            if (!count) return;
            next = Math.max(0, Math.min(next, count - 1));

            var previous = inst.index;
            var first = !inst.started;
            var changed = next !== previous || first;
            var dir = next > previous ? 1 : -1;
            var animate = !inst.reduced && !first && changed && options.animate !== false;

            inst.index = next;
            inst.started = true;

            inst.tabs.forEach(function (tab, i) {
                var selected = i === next;
                tab.setAttribute('aria-selected', String(selected));
                tab.tabIndex = selected ? 0 : -1;
            });

            if (changed) {
                var outgoing = inst.panels[previous];
                var incoming = inst.panels[next];

                inst.panels.forEach(function (panel, i) {
                    if (i === next) panel.removeAttribute('hidden');
                    else if (i !== previous || !animate) panel.setAttribute('hidden', '');
                });

                if (animate && outgoing && incoming && outgoing !== incoming) {
                    gsap.killTweensOf([outgoing, incoming]);
                    /* Opacity, not autoAlpha: the `hidden` attribute owns
                       presence here, and a visibility flip on the same frame
                       would make the panel unfocusable while it is on screen. */
                    gsap.to(outgoing, {
                        x: -SLIDE * dir,
                        opacity: 0,
                        duration: DURATION.out,
                        ease: 'power2.in',
                        onComplete: function () {
                            outgoing.setAttribute('hidden', '');
                            gsap.set(outgoing, { x: 0, opacity: 1 });
                        }
                    });
                    gsap.fromTo(incoming,
                        { x: SLIDE * dir, opacity: 0 },
                        { x: 0, opacity: 1, duration: DURATION.in, delay: DURATION.inDelay, ease: EASE }
                    );
                } else if (incoming) {
                    gsap.set(inst.panels, { x: 0, opacity: 1 });
                }
            }

            placeIndicator(inst, animate);
            setBoxHeight(inst, animate);
            scrollTabIntoView(inst, animate);
            /* Not on the first paint: a page that opens with no hash should keep
               its clean URL until the reader actually picks a tab. */
            if (!first) syncHash(inst);

            if (options.focus) inst.tabs[next].focus();

            if (changed && !first) {
                inst.root.dispatchEvent(new CustomEvent('tabchange', {
                    bubbles: true,
                    detail: {
                        index: next,
                        previousIndex: previous,
                        id: inst.tabs[next].dataset.tab || inst.tabs[next].id,
                        tab: inst.tabs[next],
                        panel: inst.panels[next]
                    }
                }));
            }
        };

        /* Re-measure everything and re-seat the indicator without motion. Called
           on resize, on fonts.ready, and after the demo's theme or style switch,
           either of which can change padding under a measured element. */
        var refresh = function (inst) {
            inst.reduced = isReducedNow();
            measurePanels(inst);
            placeIndicator(inst, false);
            setBoxHeight(inst, false);
            updateEdges(inst);
            scrollTabIntoView(inst, false);
        };

        var setup = function (root) {
            var list = root.querySelector('[role="tablist"]');
            var scroller = root.querySelector('.tabs__scroller') || list;
            var box = root.querySelector('.tabs__panels');
            if (!list || !box) return null;

            var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
            var panels = tabs.map(function (tab) {
                return document.getElementById(tab.getAttribute('aria-controls'));
            }).filter(Boolean);
            if (!tabs.length || panels.length !== tabs.length) return null;

            var inst = {
                root: root,
                list: list,
                scroller: scroller,
                box: box,
                strip: root.querySelector('.tabs__strip') || scroller,
                tabs: tabs,
                panels: panels,
                indicator: root.querySelector('.tabs__indicator'),
                hover: root.querySelector('.tabs__hover'),
                heights: [],
                index: Math.max(0, tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; })),
                started: false,
                reduced: isReducedNow(),
                useHash: root.hasAttribute('data-tabs-hash'),
                handlers: []
            };

            if (inst.useHash) {
                var wanted = decodeURIComponent(String(location.hash || '').replace('#', ''));
                var found = tabs.findIndex(function (t) { return t.dataset.tab === wanted; });
                if (found > -1) inst.index = found;
            }

            panels.forEach(function (panel, i) {
                if (i === inst.index) panel.removeAttribute('hidden');
                else panel.setAttribute('hidden', '');
            });

            return inst;
        };

        var bind = function (inst, isFine) {
            var on = function (target, type, fn, opts) {
                target.addEventListener(type, fn, opts);
                inst.handlers.push([target, type, fn, opts]);
            };

            inst.tabs.forEach(function (tab, i) {
                on(tab, 'click', function () { activate(inst, i, { focus: true }); });
                if (isFine) {
                    on(tab, 'mouseenter', function () { setHoverPill(inst, tab); });
                }
            });

            if (isFine) on(inst.list, 'mouseleave', function () { hideHoverPill(inst); });

            /* Automatic activation: the arrow keys move focus AND select, which
               is the WAI-ARIA default for tabs whose panels are already loaded. */
            on(inst.list, 'keydown', function (event) {
                var count = inst.tabs.length;
                var next = null;
                if (event.key === 'ArrowRight') next = (inst.index + 1) % count;
                else if (event.key === 'ArrowLeft') next = (inst.index - 1 + count) % count;
                else if (event.key === 'Home') next = 0;
                else if (event.key === 'End') next = count - 1;
                if (next === null) return;
                event.preventDefault();
                activate(inst, next, { focus: true });
            });

            on(inst.scroller, 'scroll', function () { updateEdges(inst); }, { passive: true });

            var onResize = function () { refresh(inst); };
            on(window, 'resize', onResize);

            if (inst.useHash) {
                on(window, 'hashchange', function () {
                    var wanted = decodeURIComponent(String(location.hash || '').replace('#', ''));
                    var found = inst.tabs.findIndex(function (t) { return t.dataset.tab === wanted; });
                    if (found > -1 && found !== inst.index) activate(inst, found, {});
                });
            }
        };

        var unbind = function (inst) {
            inst.handlers.forEach(function (entry) {
                entry[0].removeEventListener(entry[1], entry[2], entry[3]);
            });
            inst.handlers.length = 0;
        };

        var ctx = gsap.context(function slidingTabsContext() {
            var mm = gsap.matchMedia();

            /* Complementary pairs on BOTH axes. A set like { isFine, isReduced }
               leaves a coarse-pointer device with motion allowed matching
               nothing at all, and the component then never initialises. */
            mm.add({
                isFine: '(hover: hover) and (pointer: fine)',
                isCoarse: '(hover: none), (pointer: coarse)',
                isMotion: '(prefers-reduced-motion: no-preference)',
                isReduced: '(prefers-reduced-motion: reduce)'
            }, function tabsMatchMedia(context) {
                var isFine = !!context.conditions.isFine;
                var reduced = !!context.conditions.isReduced;

                var roots = Array.prototype.slice.call(document.querySelectorAll('[data-tabs]'));
                var built = [];

                roots.forEach(function (root) {
                    var existing = instances.filter(function (i) { return i.root === root; })[0];
                    var inst = setup(root);
                    if (!inst) return;
                    /* Keep the selected tab across a media-query re-run, so
                       rotating a phone does not throw the reader back to tab 1. */
                    if (existing) {
                        inst.index = existing.index;
                        inst.panels.forEach(function (panel, i) {
                            if (i === inst.index) panel.removeAttribute('hidden');
                            else panel.setAttribute('hidden', '');
                        });
                    }
                    inst.reduced = reduced;
                    built.push(inst);
                    bind(inst, isFine);

                    measurePanels(inst);
                    activate(inst, inst.index, { animate: false });
                    updateEdges(inst);

                    /* Inter changes the tab widths once it lands; anything
                       measured before that is measured against the fallback. */
                    if (document.fonts && document.fonts.ready) {
                        document.fonts.ready.then(function () { refresh(inst); });
                    }
                });

                instances = built;

                return function cleanup() {
                    built.forEach(unbind);
                };
            });
        });

        /* Programmatic control, for a router or a "see the files" link elsewhere
           on the page. destroy() calls ctx.revert(), never ctx.kill(): kill drops
           the context without running the cleanup above, leaving the listeners
           and the inline heights behind. */
        window.slidingTabs = {
            select: function (key, root) {
                instances.forEach(function (inst) {
                    if (root && inst.root !== root) return;
                    var index = typeof key === 'number'
                        ? key
                        : inst.tabs.findIndex(function (t) { return t.dataset.tab === key; });
                    if (index > -1) activate(inst, index, {});
                });
            },
            refresh: function () { instances.forEach(refresh); },
            destroy: function () {
                instances.forEach(unbind);
                instances = [];
                ctx.revert();
            }
        };
    });
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){document.documentElement.classList.add("has-js");var e=.34,t=.36,n=.18,i=.3,o=.08,r=.24,a=.35,s="power3.out",d=[],c=function(){return window.matchMedia("(prefers-reduced-motion: reduce)").matches},u=function(e){e.heights=e.panels.map(function(e){if(!e.hasAttribute("hidden"))return e.offsetHeight;e.style.visibility="hidden",e.removeAttribute("hidden");var t=e.offsetHeight;return e.setAttribute("hidden",""),e.style.visibility="",t})},l=function(t,n){var i=t.tabs[t.index];if(i){var o={x:i.offsetLeft,width:i.offsetWidth};n?gsap.to(t.indicator,Object.assign({duration:e,ease:s,overwrite:"auto"},o)):gsap.set(t.indicator,o)}},f=function(e,n){var i=e.heights[e.index];i&&(n?gsap.to(e.box,{height:i,duration:t,ease:s,overwrite:"auto"}):gsap.set(e.box,{height:i}))},h=function(e){var t=e.scroller,n=e.strip,i=t.scrollWidth>t.clientWidth+1;n.classList.toggle("is-overflow",i),n.classList.toggle("is-start",i&&t.scrollLeft>2),n.classList.toggle("is-end",i&&t.scrollLeft<t.scrollWidth-t.clientWidth-2)},p=function(e,t){var n=e.scroller,i=e.tabs[e.index];if(i&&!(n.scrollWidth<=n.clientWidth+1)){var o=n.scrollLeft,r=i.offsetLeft-16,s=i.offsetLeft+i.offsetWidth+16;r<n.scrollLeft?o=r:s>n.scrollLeft+n.clientWidth&&(o=s-n.clientWidth),o=Math.max(0,Math.min(o,n.scrollWidth-n.clientWidth)),Math.abs(o-n.scrollLeft)<1||(t?gsap.to(n,{scrollLeft:o,duration:a,ease:"power2.out",overwrite:"auto"}):n.scrollLeft=o)}},b=function(e,t,r){var a=r||{},d=e.tabs.length;if(d){t=Math.max(0,Math.min(t,d-1));var c=e.index,u=!e.started,h=t!==c||u,b=t>c?1:-1,v=!e.reduced&&!u&&h&&!1!==a.animate;if(e.index=t,e.started=!0,e.tabs.forEach(function(e,n){var i=n===t;e.setAttribute("aria-selected",String(i)),e.tabIndex=i?0:-1}),h){var g=e.panels[c],m=e.panels[t];e.panels.forEach(function(e,n){n===t?e.removeAttribute("hidden"):n===c&&v||e.setAttribute("hidden","")}),v&&g&&m&&g!==m?(gsap.killTweensOf([g,m]),gsap.to(g,{x:-24*b,opacity:0,duration:n,ease:"power2.in",onComplete:function(){g.setAttribute("hidden",""),gsap.set(g,{x:0,opacity:1})}}),gsap.fromTo(m,{x:24*b,opacity:0},{x:0,opacity:1,duration:i,delay:o,ease:s})):m&&gsap.set(e.panels,{x:0,opacity:1})}l(e,v),f(e,v),p(e,v),u||function(e){if(e.useHash){var t=e.tabs[e.index].dataset.tab;if(t)try{history.replaceState(null,"","#"+t)}catch(e){}}}(e),a.focus&&e.tabs[t].focus(),h&&!u&&e.root.dispatchEvent(new CustomEvent("tabchange",{bubbles:!0,detail:{index:t,previousIndex:c,id:e.tabs[t].dataset.tab||e.tabs[t].id,tab:e.tabs[t],panel:e.panels[t]}}))}},v=function(e){e.reduced=c(),u(e),l(e,!1),f(e,!1),h(e),p(e,!1)},g=function(e,t){var n=function(t,n,i,o){t.addEventListener(n,i,o),e.handlers.push([t,n,i,o])};e.tabs.forEach(function(i,o){n(i,"click",function(){b(e,o,{focus:!0})}),t&&n(i,"mouseenter",function(){!function(e,t){if(e.hover){var n=Number(gsap.getProperty(e.hover,"opacity"))>.01,i={x:t.offsetLeft,width:t.offsetWidth,opacity:1};!n||e.reduced?gsap.set(e.hover,i):gsap.to(e.hover,Object.assign({duration:r,ease:s,overwrite:"auto"},i))}}(e,i)})}),t&&n(e.list,"mouseleave",function(){!function(e){e.hover&&gsap.to(e.hover,{opacity:0,duration:e.reduced?0:.18,ease:"power2.out",overwrite:"auto"})}(e)}),n(e.list,"keydown",function(t){var n=e.tabs.length,i=null;"ArrowRight"===t.key?i=(e.index+1)%n:"ArrowLeft"===t.key?i=(e.index-1+n)%n:"Home"===t.key?i=0:"End"===t.key&&(i=n-1),null!==i&&(t.preventDefault(),b(e,i,{focus:!0}))}),n(e.scroller,"scroll",function(){h(e)},{passive:!0}),n(window,"resize",function(){v(e)}),e.useHash&&n(window,"hashchange",function(){var t=decodeURIComponent(String(location.hash||"").replace("#","")),n=e.tabs.findIndex(function(e){return e.dataset.tab===t});n>-1&&n!==e.index&&b(e,n,{})})},m=function(e){e.handlers.forEach(function(e){e[0].removeEventListener(e[1],e[2],e[3])}),e.handlers.length=0},y=gsap.context(function(){gsap.matchMedia().add({isFine:"(hover: hover) and (pointer: fine)",isCoarse:"(hover: none), (pointer: coarse)",isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){var t=!!e.conditions.isFine,n=!!e.conditions.isReduced,i=Array.prototype.slice.call(document.querySelectorAll("[data-tabs]")),o=[];return i.forEach(function(e){var i=d.filter(function(t){return t.root===e})[0],r=function(e){var t=e.querySelector('[role="tablist"]'),n=e.querySelector(".tabs__scroller")||t,i=e.querySelector(".tabs__panels");if(!t||!i)return null;var o=Array.prototype.slice.call(t.querySelectorAll('[role="tab"]')),r=o.map(function(e){return document.getElementById(e.getAttribute("aria-controls"))}).filter(Boolean);if(!o.length||r.length!==o.length)return null;var a={root:e,list:t,scroller:n,box:i,strip:e.querySelector(".tabs__strip")||n,tabs:o,panels:r,indicator:e.querySelector(".tabs__indicator"),hover:e.querySelector(".tabs__hover"),heights:[],index:Math.max(0,o.findIndex(function(e){return"true"===e.getAttribute("aria-selected")})),started:!1,reduced:c(),useHash:e.hasAttribute("data-tabs-hash"),handlers:[]};if(a.useHash){var s=decodeURIComponent(String(location.hash||"").replace("#","")),d=o.findIndex(function(e){return e.dataset.tab===s});d>-1&&(a.index=d)}return r.forEach(function(e,t){t===a.index?e.removeAttribute("hidden"):e.setAttribute("hidden","")}),a}(e);r&&(i&&(r.index=i.index,r.panels.forEach(function(e,t){t===r.index?e.removeAttribute("hidden"):e.setAttribute("hidden","")})),r.reduced=n,o.push(r),g(r,t),u(r),b(r,r.index,{animate:!1}),h(r),document.fonts&&document.fonts.ready&&document.fonts.ready.then(function(){v(r)}))}),d=o,function(){o.forEach(m)}})});window.slidingTabs={select:function(e,t){d.forEach(function(n){if(!t||n.root===t){var i="number"==typeof e?e:n.tabs.findIndex(function(t){return t.dataset.tab===e});i>-1&&b(n,i,{})}})},refresh:function(){d.forEach(v)},destroy:function(){d.forEach(m),d=[],y.revert()}}}else console.warn("[sliding-indicator-tabs] GSAP is required but was not found.")},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------- Theme tokens ----------
   Every colour, radius and shadow the component and the demo ground use is a
   custom property on <body>, keyed by data-variant. Two values of one design:
   `light` and `dark`. Nothing in assets/script.js reads the theme name, so
   re-valuing one block is the whole of a rebrand. */
body,
body[data-variant="light"] {
  color-scheme: light;

  --ground: #f2f2f1;          /* the page: grey paper, not white */
  --ground-2: #e9e9e7;        /* the pill track, the toolbar strip */
  --raised: #fbfbfa;          /* the tabs card */
  --hover: #e2e2df;           /* the hover pill */
  --ink: #141412;
  --ink-2: #4b4b47;           /* body copy, values */
  --ink-3: #676762;           /* labels, resting tab text */
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);

  --accent: #635bff;          /* the indicator, the badge, the focus ring */
  --accent-ink: #ffffff;      /* text on the accent: 4.7:1 */
  --accent-wash: #e1e0f3;     /* solid: the accent at ~12% over --ground */

  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-xs: 4px;
}

body[data-variant="dark"] {
  color-scheme: dark;

  --ground: #141414;          /* the site's deep */
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #262626;
  --ink: #f1efea;             /* the site's chalk */
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);

  --accent: #635bff;
  --accent-ink: #ffffff;
  --accent-wash: #22213e;     /* solid: the accent at ~18% over the dark ground */

  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
}

/* ---------- The ground (demo furniture) ----------
   The .stage rules centre the component at the width a product would give it.
   They are not part of the component; assets/script.js never reads a .stage
   class, and nothing here is needed to drop the tabs into a page. */
html { overflow-x: clip; }

body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px;                 /* the demo toolbar strip */
}

::selection { background: var(--accent); color: var(--accent-ink); }

.stage {
  min-height: calc(100svh - 44px);
  display: grid;
  /* minmax(0, 1fr), not a bare auto column: the tab list is min-width:
     max-content, so an auto-sized grid column grows to the full width of the
     tabs and pushes the card past a narrow viewport. */
  grid-template-columns: minmax(0, 1fr);
  align-content: center;
  justify-items: center;
  padding: 32px 20px;
}

.stage__inner { width: 100%; max-width: 720px; min-width: 0; }

/* ---------- The component ---------- */
.tabs {
  max-width: 100%;
  background: var(--raised);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
}

/* The scroller is horizontal only, so it carries NO data-lenis-prevent: that
   attribute has no axis check and would hand the vertical wheel back to the
   browser for as long as the pointer sits over the strip. */
.tabs__scroller {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.tabs__scroller::-webkit-scrollbar { display: none; }

/* The edge fades are overlays on the strip, never a mask on the scroller:
   a masked-out region of an element is not hit-tested in Chromium, so a
   mask here would quietly eat taps on the half-visible tab it is fading. */
.tabs__strip { position: relative; min-width: 0; }

.tabs__strip::before,
.tabs__strip::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 32px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
  z-index: 3;
}

.tabs__strip::before { left: 0; background: linear-gradient(90deg, var(--raised), transparent); }
.tabs__strip::after { right: 0; background: linear-gradient(270deg, var(--raised), transparent); }
.tabs__strip.is-start::before { opacity: 1; }
.tabs__strip.is-end::after { opacity: 1; }

.tabs__list {
  position: relative;              /* the indicator's offsetParent */
  display: flex;
  align-items: stretch;
  gap: 2px;
  min-width: max-content;
  padding: 0 8px;
  border-bottom: 1px solid var(--line);
}

.tabs__tab {
  position: relative;
  z-index: 2;
  appearance: none;
  border: 0;
  background: none;
  cursor: pointer;
  font: inherit;
  font-weight: 500;
  color: var(--ink-3);
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 42px;
  padding: 0 14px;
  white-space: nowrap;
  transition: color 0.18s ease;
}

.tabs__tab:hover { color: var(--ink); }
.tabs__tab[aria-selected="true"] { color: var(--ink); font-weight: 600; }

.tabs__tab:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -3px;
  border-radius: var(--radius-sm);
}

.tabs__badge {
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 9px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 0.6875rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.18s ease 0.06s, color 0.18s ease 0.06s;
}

.tabs__sr {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* The indicator and the hover pill are both positioned from JS with `x`, so
   neither carries a CSS transform or a CSS `left`: an offset in both places
   doubles it. */
.tabs__indicator,
.tabs__hover {
  position: absolute;
  left: 0;
  pointer-events: none;
}

.tabs__indicator {
  z-index: 1;
  bottom: -1px;
  height: 2px;
  border-radius: 2px 2px 0 0;
  background: var(--accent);
}

.tabs__hover {
  z-index: 0;
  top: 5px;
  bottom: 5px;
  border-radius: var(--radius-sm);
  background: var(--hover);
  opacity: 0;
}

/* The pill style: a segmented control at tab scale. The indicator becomes the
   filled segment and the selected label flips to the accent's ink, a beat
   behind the pill so the colour arrives with it rather than with the click. */
.tabs[data-style="pill"] .tabs__list {
  gap: 4px;
  padding: 6px 8px;
  border-bottom: 1px solid var(--line);
}

.tabs[data-style="pill"] .tabs__tab {
  min-height: 34px;
  border-radius: var(--radius-sm);
  /* Deselecting darkens the label quickly, so the tab the pill is leaving is
     never white-on-white for a beat. */
  transition: color 0.12s ease;
}

.tabs[data-style="pill"] .tabs__indicator {
  top: 6px;
  bottom: 6px;
  height: auto;
  border-radius: var(--radius-sm);
}

.tabs[data-style="pill"] .tabs__hover { top: 6px; bottom: 6px; }

/* The selected pill tab also paints the accent under itself, a beat AFTER the
   indicator has finished travelling (0.3s against the indicator's 0.34s), so
   the fill lands invisibly beneath the pill. It is not decoration: it means the
   white label has its own opaque backdrop, so a contrast checker measures the
   pair a reader actually sees instead of the card behind them. Deselecting
   clears it instantly, because the pill leaves instantly too. */
.tabs[data-style="pill"] .tabs__tab[aria-selected="true"] {
  color: var(--accent-ink);
  background: var(--accent);
  transition: color 0.16s ease 0.14s, background-color 0.01s linear 0.3s;
}

.tabs[data-style="pill"] .tabs__tab[aria-selected="true"] .tabs__badge {
  background: var(--accent-ink);
  color: var(--accent);
}

/* ---------- Panels ---------- */
.tabs__panels {
  position: relative;
  overflow: hidden;
}

.tabs__panel {
  position: absolute;
  inset: 0 auto auto 0;
  width: 100%;
  padding: 20px 22px 24px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.tabs__panel[hidden] { display: none; }

.tabs__panel:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -4px;
  border-radius: var(--radius-sm);
}

.tabs__lead {
  color: var(--ink-2);
  max-width: 58ch;
}

.tabs__facts {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px 28px;
  border-top: 1px solid var(--line);
  padding-top: 16px;
}

.tabs__facts > div { display: flex; flex-direction: column; gap: 3px; }

.tabs__facts dt {
  font-size: 0.75rem;
  color: var(--ink-3);
}

.tabs__facts dd {
  color: var(--ink);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

/* ---------- Responsive ---------- */
@media (max-width: 560px) {
  .stage { padding: 20px 12px; }
  .tabs__panel { padding: 18px 16px 20px; }
  .tabs__facts { grid-template-columns: 1fr; gap: 12px; }
}

/* Hover states are gated so a tap on a touch device cannot pin one open. */
@media (hover: none) {
  .tabs__tab:hover { color: var(--ink-3); }
  .tabs__tab[aria-selected="true"]:hover { color: var(--ink); }
  .tabs[data-style="pill"] .tabs__tab[aria-selected="true"]:hover { color: var(--accent-ink); }
}

@media (prefers-reduced-motion: reduce) {
  .tabs *,
  .tabs__tab,
  .tabs__badge {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}

Content tabs whose indicator slides and stretches between tabs, panels that change in the direction you are travelling, and a panel box that morphs between heights so nothing below the component jumps.

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • The indicator is measured from each tab's own offsetLeft / offsetWidth, so a two-word label and a one-word label both get an indicator the exact width of the tab

  • Panels change directionally: the outgoing panel leaves the way you are travelling, the incoming one arrives from the other side a frame later

  • The panel box tweens between panel heights, so switching to a shorter panel never snaps the content below it

  • A soft hover pill follows the pointer across the strip, gated to hover: hover pointers so a tap cannot pin it open

  • Re-measures on resize, on document.fonts.ready and after a theme change, so an indicator is never left sitting off its tab

  • An overflowing tab list scrolls sideways with a faded edge, and activating a tab scrolls it into view

  • Roles: tablist, tab with aria-selected and aria-controls, tabpanel with aria-labelledby, and a roving tabindex

Quick Start

1. Add to your <head>:

<link rel="stylesheet" href="assets/style.css">

2. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="assets/script.js"></script>

3. Copy the component markup from index.html into your page.

The skeleton the script looks for:

<section class="tabs" data-tabs data-style="underline">
  <div class="tabs__strip">
    <div class="tabs__scroller">
      <div class="tabs__list" role="tablist" aria-label="Sections">
        <span class="tabs__hover" aria-hidden="true"></span>
        <span class="tabs__indicator" aria-hidden="true"></span>

        <button class="tabs__tab" type="button" role="tab" id="tab-one" data-tab="one"
                aria-controls="panel-one" aria-selected="true" tabindex="0">One</button>
        <!-- …more tabs -->
      </div>
    </div>
  </div>

  <div class="tabs__panels">
    <div class="tabs__panel" role="tabpanel" id="panel-one" aria-labelledby="tab-one" tabindex="0">…</div>
    <!-- …more panels, each with the hidden attribute -->
  </div>
</section>

What matters:

  • [data-tabs] on the root, [role="tablist"] for the strip, [role="tab"] for each tab, and each tab's aria-controls pointing at its panel's id. Everything is found through those, so the class names can be renamed in the CSS.
  • .tabs__indicator is optional in the sense that the tabs work without it, but it is the whole point. Give it no CSS transform and no CSS left: the script positions it with x, and an offset in both places doubles it.
  • .tabs__hover is optional; delete it and the pointer highlight simply does not exist.
  • .tabs__strip and .tabs__scroller are only needed if the list can overflow. Keep both if it might: the scroller does the scrolling and the strip carries the edge fades.
  • The panels are absolutely stacked inside .tabs__panels, which is what lets the box tween between heights. Give exactly one panel no hidden attribute; the script hides the rest on load.

Options on the root:

Attribute Values Default What it does
data-style underline, pill underline Underline bar, or a filled sliding segment at tab scale
data-tabs-hash present / absent absent Syncs location.hash with each tab's data-tab, and reads it on load
data-tab (on a tab) any slug — The tab's key, used for the hash and for select()

Changing data-style at runtime changes the strip's padding, so follow it with a resize event or a slidingTabs.refresh() call and the indicator re-measures.

The variant switcher, the style switcher and the .stage wrapper in index.html are demo furniture, not part of the component; leave them behind.

Keyboard & Accessibility

Key What it does
Tab Moves into the tab strip, landing on the selected tab only (roving tabindex), then on into the panel
Arrow Right / Arrow Left Moves to the next or previous tab and selects it, wrapping at both ends
Home / End Selects the first or last tab
Enter / Space Selects the focused tab (native button behaviour)
  • Roles and state: role="tablist" with an aria-label, role="tab" with aria-selected and aria-controls, role="tabpanel" with aria-labelledby. Inactive panels carry the hidden attribute, so they are out of the accessibility tree and out of the tab order entirely; the active panel is focusable with tabindex="0" and anything focusable inside it is reachable as normal.
  • Activation is automatic: an arrow key selects as it moves, which is the WAI-ARIA default for tabs whose panels are already in the page. Focus follows selection, so a screen reader announces the panel change.
  • prefers-reduced-motion: reduce: the same end states with every duration at zero. The indicator, the panel and the box height land in one frame; nothing is disabled and nothing is hidden.
  • Hover styling is gated behind hover: hover, so a tap on a phone cannot leave a tab looking hovered.
  • The tab strip scrolls sideways only, so it carries no data-lenis-prevent: that attribute has no axis check and would hand the vertical wheel back to the browser for as long as the pointer sat over the strip.

How It Works

The indicator. Each tab's position is read as offsetLeft / offsetWidth against the tab list, never getBoundingClientRect(), so a page zoom cannot come back multiplied and land the indicator at a multiple of its own offset. gsap.to then tweens the indicator's x and width to those numbers with power3.out over 340ms. Everything measured re-runs on resize, on document.fonts.ready (a webfont changes every tab width when it lands) and on the theme change the demo's toggle dispatches.

The panel change. The panels are absolutely stacked, so both can be on screen at once. A change tweens three things on one beat: the indicator leads, the box tweens to the new panel's height, and 80ms later the incoming panel slides 24px in from the side you came from while the outgoing one leaves the other way. The outgoing panel takes its hidden attribute back in the tween's onComplete and is reset to x: 0, opacity: 1 there, so a transparent panel is never left covering the live one and the next measurement is not taken through a half-finished tween.

The heights. A hidden panel has no height to measure, so the measure pass removes the hidden attribute with visibility: hidden set, reads offsetHeight, and puts both back. Without that the box collapses to zero on the first change.

Reduced motion and pointers. gsap.matchMedia uses complementary condition pairs on both axes (hover/pointer and motion), so every device matches something: a set that only names fine pointers leaves a coarse-pointer phone matching nothing and the component never initialises at all.

Events

The root dispatches a bubbling tabchange after every change:

document.querySelector('[data-tabs]').addEventListener('tabchange', (event) => {
  const { id, index, previousIndex, tab, panel } = event.detail;
  console.log(`moved to ${id} (${previousIndex} -> ${index})`);
});

Programmatic Control

window.slidingTabs.select('files');   // by data-tab, or by index
window.slidingTabs.refresh();         // re-measure after you change layout yourself
window.slidingTabs.destroy();         // removes listeners and reverts the GSAP context

destroy() calls ctx.revert(), never ctx.kill(): kill() drops the context without running its cleanup, leaving the listeners and the inline heights behind. It is the call to make on an SPA route change.

Customisation

  • Colours live in assets/style.css as custom properties per theme. --accent is the indicator, the badge and the focus ring; --accent-ink is the text on it (keep the pair above 4.5:1).
  • Timings and easing are the DURATION object at the top of assets/script.js. SLIDE is how far the panels travel, in pixels; much past 32 and the change stops reading as one move.
  • The indicator's shape is .tabs__indicator in the underline block and in the [data-style="pill"] block. Height, radius and inset are all CSS; the script only supplies x and width.
  • Density: tabs are 42px in the underline style and 34px in the pill style, on an 8px rhythm.

Themes

Ships in two themes, light (the default) and dark: one design at two token values.

<body data-variant="dark">

Nothing else changes: same markup, same script, and no theme name is ever read in JavaScript. The demo's toggle and the ?variant=dark URL parameter only set that attribute.

To re-value it for your own brand, change the custom properties in the body[data-variant="light"] and body[data-variant="dark"] blocks at the top of assets/style.css: --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --shadow and the three radii. Every colour the component uses is one of those, so a rebrand never needs a selector override. Give text a solid colour rather than a translucent one, or a contrast checker cannot measure it.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework