What's Included
index.html: the demo page and the markup to copyassets/style.css: component styles, plain CSS custom properties for coloursassets/script.js: readable, commented source with anonReadyguardThe 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 tabPanels 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: hoverpointers so a tap cannot pin it openRe-measures on
resize, ondocument.fonts.readyand after a theme change, so an indicator is never left sitting off its tabAn overflowing tab list scrolls sideways with a faded edge, and activating a tab scrolls it into view
Roles:
tablist,tabwitharia-selectedandaria-controls,tabpanelwitharia-labelledby, and a rovingtabindex
Quick Start
1. Add to your <head>:
Code snippet omitted: it ships with the download.
2. Add before the closing </body> tag:
Code snippet omitted: it ships with the download.
3. Copy the component markup from index.html into your page.
The skeleton the script looks for:
Code snippet omitted: it ships with the download.
What matters:
[data-tabs]on the root,[role="tablist"]for the strip,[role="tab"]for each tab, and each tab'saria-controlspointing at its panel'sid. Everything is found through those, so the class names can be renamed in the CSS..tabs__indicatoris optional in the sense that the tabs work without it, but it is the whole point. Give it no CSStransformand no CSSleft: the script positions it withx, and an offset in both places doubles it..tabs__hoveris optional; delete it and the pointer highlight simply does not exist..tabs__stripand.tabs__scrollerare 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 nohiddenattribute; 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 anaria-label,role="tab"witharia-selectedandaria-controls,role="tabpanel"witharia-labelledby. Inactive panels carry thehiddenattribute, so they are out of the accessibility tree and out of the tab order entirely; the active panel is focusable withtabindex="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.
Customisation
- Colours live in
assets/style.cssas custom properties per theme.--accentis the indicator, the badge and the focus ring;--accent-inkis the text on it (keep the pair above 4.5:1). - Timings and easing are the
DURATIONobject at the top ofassets/script.js.SLIDEis how far the panels travel, in pixels; much past 32 and the change stops reading as one move. - The indicator's shape is
.tabs__indicatorin the underline block and in the[data-style="pill"]block. Height, radius and inset are all CSS; the script only suppliesxandwidth. - 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.
Code snippet omitted: it ships with the download.
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
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.