Sliding Indicator Tabs

£5

Content tabs whose indicator slides and stretches between tabs while the panels change directionally and the box morphs between heights.

intermediate
5 more details
tabskeyboard-navigationstate-transitionhover-effectmicro-interaction
Sliding Indicator Tabs - UI component preview

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

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.

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

Paid UI element

Purchase to unlock the code.

Buying Sliding Indicator Tabs opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£5 Standard license, unlimited projects

Browse UI elements

Documentation

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>:

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'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.

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.

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.

Your cart

Your cart is empty

The Vault £99

Everything in the catalogue, plus everything we release next.