Tab System with Autoplay Option
A GSAP-animated tab system with an optional autoplay mode. Each tab click or autoplay tick runs a coordinated timeline: the outgoing tab's details collapse and its progress bar resets, the incoming visual cross-fades in, and a progress bar scales from 0 to 1 over the autoplay duration before advancing to the next tab. Multiple independent instances per page are supported.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>Code
<div data-tabs-autoplay-duration="5000" data-tabs="wrapper" data-tabs-autoplay="true" class="tab-layout__wrap">
<div class="tab-layout__col">
<div class="tab-content__wrap">
<div class="tab-content__inner">
<div class="tab-content__top">
<h1 class="tab-heading">Explore the perks of being a member</h1>
</div>
<div role="tablist" class="tab-content__bottom">
<a role="tab" data-tabs="content-item" href="#" class="tab-content__item w-inline-block">
<div class="tab-content__item-main">
<div class="content-item__nr">
<div>01</div>
</div>
<h2 class="content-item__heading">Explore the vault</h2>
</div>
<div data-tabs="item-details" class="tab-content__item-detail">
<div class="tab-description__spacer"></div>
<p class="tab-description">The Vault is where everything lives. Organized into clear categories, it's designed to make browsing easy. Whether you're looking for a specific slider, animation, or utility, our quick-find search has you covered.</p>
<div class="tab-description__spacer"></div>
</div>
<div class="tab-content__item-bottom">
<div data-tabs="item-progress" class="tab-progress"></div>
</div>
</a>
<a role="tab" data-tabs="content-item" href="#" class="tab-content__item w-inline-block">
<div class="tab-content__item-main">
<div class="content-item__nr">
<div>02</div>
</div>
<h2 class="content-item__heading">Learn from videos</h2>
</div>
<div data-tabs="item-details" class="tab-content__item-detail">
<div class="tab-description__spacer"></div>
<p class="tab-description">We also include videos that explain the concept, go deeper on the subject, or maybe might spark some new ideas for the resources that you're using.</p>
<div class="tab-description__spacer"></div>
</div>
<div class="tab-content__item-bottom">
<div data-tabs="item-progress" class="tab-progress"></div>
</div>
</a>
<a role="tab" data-tabs="content-item" href="#" class="tab-content__item w-inline-block">
<div class="tab-content__item-main">
<div class="content-item__nr">
<div>03</div>
</div>
<h2 class="content-item__heading">Implement Osmo Basics</h2>
</div>
<div data-tabs="item-details" class="tab-content__item-detail">
<div class="tab-description__spacer"></div>
<p class="tab-description">These are the foundations you'll rely on for every award-worthy project. Master the basics, and the flashy stuff will actually have something solid to stand on.</p>
<div class="tab-description__spacer"></div>
</div>
<div class="tab-content__item-bottom">
<div data-tabs="item-progress" class="tab-progress"></div>
</div>
</a>
</div>
</div>
</div>
</div>
<div class="tab-layout__col">
<div aria-live="polite" role="region" class="tab-visual__wrap">
<div id="tab1" data-tabs="visual-item" role="tabpanel" class="tab-visual__item active">
<div class="tab-visual__inner"><img src="https://cdn.prod.website-files.com/679013b2e01832b21eba1b5b/679016ba86e862ccd6750213_tab-asset-vault.avif" loading="lazy" alt="" class="tab-image"></div>
</div>
<div id="tab2" data-tabs="visual-item" role="tabpanel" class="tab-visual__item">
<div class="tab-visual__inner"><img src="https://cdn.prod.website-files.com/679013b2e01832b21eba1b5b/679016ba8f0f937c2b5b1d0f_tab-asset-videos.avif" loading="lazy" alt="" class="tab-image"></div>
</div>
<div id="tab3" data-tabs="visual-item" role="tabpanel" class="tab-visual__item">
<div class="tab-visual__inner"><img src="https://cdn.prod.website-files.com/679013b2e01832b21eba1b5b/679016bab4910a3b7a9e0e64_tab-asset-basics.avif" loading="lazy" alt="" class="tab-image"></div>
</div>
</div>
</div>
</div>.tab-layout__wrap {
z-index: 1;
grid-row-gap: 3em;
flex-flow: wrap;
padding-left: 1em;
padding-right: 1em;
display: flex;
position: relative;
}
.tab-layout__col {
width: 50%;
padding-left: .5em;
padding-right: .5em;
}
.tab-content__inner {
grid-column-gap: 3em;
grid-row-gap: 3em;
flex-flow: column;
justify-content: space-between;
align-items: flex-start;
min-height: 100%;
padding-top: 1em;
padding-bottom: 0;
padding-right: 2.5em;
display: flex;
}
.tab-content__top {
grid-column-gap: 2em;
grid-row-gap: 2em;
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
display: flex;
}
.tab-heading {
margin-top: 0;
margin-bottom: 0;
font-size: 3.5em;
font-weight: 500;
line-height: 1;
}
.tab-visual__wrap {
aspect-ratio: 1.6;
height: 50em;
position: relative;
}
.tab-visual__item {
visibility: hidden;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: absolute;
}
.tab-visual__item.active {
visibility: visible;
}
.tab-visual__inner {
border: 1px solid #0003;
border-radius: .5em;
width: 100%;
height: 100%;
padding: .5em;
overflow: hidden;
}
.tab-image {
object-fit: cover;
object-position: 0% 50%;
border-radius: .25em;
width: 100%;
height: 100%;
position: relative;
}
.tab-content__wrap {
width: 100%;
max-width: 36em;
height: 100%;
margin-left: auto;
margin-right: 0;
}
.tab-content__bottom {
flex-flow: column;
justify-content: space-between;
align-items: stretch;
width: 100%;
max-width: 30em;
margin-top: 0;
margin-bottom: 0;
padding-left: 0;
display: flex;
}
.tab-content__item {
color: #131313;
width: 100%;
padding-top: 2em;
padding-bottom: 2em;
text-decoration: none;
transition: opacity .25s;
position: relative;
}
.tab-content__item-main {
grid-column-gap: 2em;
grid-row-gap: 2em;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
display: flex;
}
.content-item__nr {
color: #fff;
background-color: #131313;
border: 1px solid #131313;
border-radius: 100em;
justify-content: center;
align-items: center;
width: 2.5em;
height: 2.5em;
margin-top: .2em;
font-family: RM Mono, Arial, sans-serif;
font-size: .75em;
font-weight: 400;
transition: transform .4s cubic-bezier(.625, .05, 0, 1);
display: flex;
}
.content-item__heading {
margin-top: 0;
margin-bottom: 0;
font-size: 2em;
font-weight: 500;
line-height: 1;
}
.tab-content__item-detail {
width: 100%;
height: 0;
padding-left: 4em;
overflow: hidden;
}
.tab-description {
margin-bottom: 0;
font-size: 1em;
}
.tab-description__spacer {
padding-top: 1em;
}
.tab-content__item-bottom {
background-color: #0003;
width: 100%;
height: 1px;
transition: background-color .2s;
position: absolute;
inset: auto 0% 0%;
}
.tab-progress {
transform-origin: 0%;
transform-style: preserve-3d;
background-color: #ff4c24;
width: 100%;
height: 1px;
transform: scale3d(0, 1, 1);
}
@media screen and (max-width: 991px) {
.tab-layout__col {
width: 100%;
padding-left: 0;
padding-right: 0;
}
.tab-content__inner {
justify-content: space-between;
align-items: stretch;
padding: 0;
}
.tab-content__top {
grid-column-gap: 1.5em;
grid-row-gap: 1.5em;
}
.tab-visual__wrap {
height: auto;
padding-left: 0;
padding-right: 0;
}
.tab-visual__item {
overflow: hidden;
}
.tab-content__wrap {
max-width: none;
margin-left: 0;
}
}
@media screen and (max-width: 767px) {
.tab-layout__wrap {
grid-row-gap: 2em;
}
.tab-heading {
font-size: 2.8em;
}
.tab-visual__item {
border-radius: .25em;
}
.tab-content__bottom {
max-width: none;
}
.tab-content__item-main {
grid-column-gap: 1.5em;
grid-row-gap: 1.5em;
}
.content-item__nr {
margin-top: -.2em;
}
.content-item__heading {
font-size: 1.5em;
}
}
@media screen and (max-width: 479px) {
.tab-heading {
font-size: 3em;
}
.tab-visual__inner {
border-style: none;
border-radius: .25em;
padding: 0;
}
.tab-image {
aspect-ratio: auto;
}
.tab-content__item {
padding-top: 1.5em;
padding-bottom: 1.5em;
}
.tab-content__item-main {
grid-column-gap: 1em;
grid-row-gap: 1em;
}
.content-item__nr {
flex: none;
}
.content-item__heading {
font-size: 1.5em;
}
.tab-content__item-detail {
padding-left: 3em;
}
}function initTabSystem() {
const wrappers = document.querySelectorAll('[data-tabs="wrapper"]');
wrappers.forEach((wrapper) => {
const contentItems = wrapper.querySelectorAll('[data-tabs="content-item"]');
const visualItems = wrapper.querySelectorAll('[data-tabs="visual-item"]');
const autoplay = wrapper.dataset.tabsAutoplay === "true";
const autoplayDuration = parseInt(wrapper.dataset.tabsAutoplayDuration) || 5000;
let activeContent = null;
let activeVisual = null;
let isAnimating = false;
let progressBarTween = null;
function startProgressBar(index) {
if (progressBarTween) progressBarTween.kill();
const bar = contentItems[index].querySelector('[data-tabs="item-progress"]');
if (!bar) return;
gsap.set(bar, { scaleX: 0, transformOrigin: "left center" });
progressBarTween = gsap.to(bar, {
scaleX: 1,
duration: autoplayDuration / 1000,
ease: "power1.inOut",
onComplete: () => {
if (!isAnimating) {
const nextIndex = (index + 1) % contentItems.length;
switchTab(nextIndex);
}
},
});
}
function switchTab(index) {
if (isAnimating || contentItems[index] === activeContent) return;
isAnimating = true;
if (progressBarTween) progressBarTween.kill();
const outgoingContent = activeContent;
const outgoingVisual = activeVisual;
const outgoingBar = outgoingContent?.querySelector('[data-tabs="item-progress"]');
const incomingContent = contentItems[index];
const incomingVisual = visualItems[index];
const incomingBar = incomingContent.querySelector('[data-tabs="item-progress"]');
const tl = gsap.timeline({
defaults: { duration: 0.65, ease: "power3" },
onComplete: () => {
activeContent = incomingContent;
activeVisual = incomingVisual;
isAnimating = false;
if (autoplay) startProgressBar(index);
},
});
if (outgoingContent) {
outgoingContent.classList.remove("active");
outgoingVisual?.classList.remove("active");
tl.set(outgoingBar, { transformOrigin: "right center" })
.to(outgoingBar, { scaleX: 0, duration: 0.3 }, 0)
.to(outgoingVisual, { autoAlpha: 0, xPercent: 3 }, 0)
.to(outgoingContent.querySelector('[data-tabs="item-details"]'), { height: 0 }, 0);
}
incomingContent.classList.add("active");
incomingVisual.classList.add("active");
tl.fromTo(incomingVisual, { autoAlpha: 0, xPercent: 3 }, { autoAlpha: 1, xPercent: 0 }, 0.3)
.fromTo(incomingContent.querySelector('[data-tabs="item-details"]'), { height: 0 }, { height: "auto" }, 0)
.set(incomingBar, { scaleX: 0, transformOrigin: "left center" }, 0);
}
switchTab(0);
contentItems.forEach((item, i) =>
item.addEventListener("click", () => {
if (item === activeContent) return;
switchTab(i);
})
);
});
}
// Initialize Tab System with Autoplay Option
document.addEventListener('DOMContentLoaded', () => {
initTabSystem();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-tabs="wrapper" | attribute | — | Root container for one tab instance. The script scopes all selectors to this element, so multiple independent instances on the same page are fully supported. |
| data-tabs-autoplay | attribute ("true" | omit) | — | Set to "true" to enable autoplay. Remove or set to any other value to disable. When enabled, startProgressBar() runs after each tab switch. |
| data-tabs-autoplay-duration | attribute (number, milliseconds) | — | Duration in milliseconds for each tab before auto-advancing. Defaults to 5000 ms if omitted. Controls both the progress bar tween duration and the autoplay interval. |
| data-tabs="content-item" | attribute | — | Each clickable tab trigger. Matched to visual items by DOM index — the 1st content-item activates the 1st visual-item. |
| data-tabs="item-details" | attribute | — | The collapsible description panel inside each content item. Animated from height: 0 to height: auto on open and back to 0 on close. |
| data-tabs="item-progress" | attribute | — | The progress indicator element. GSAP scales its X axis from 0 to 1 over the autoplay duration. Replace with any animation — circle stroke, border fill, counter, etc. |
| data-tabs="visual-item" | attribute | — | Each visual panel matched to its content item by index. Cross-fades in from xPercent: 3 when its tab becomes active and fades out to xPercent: 3 when it leaves. |
Notes
- •Requires GSAP loaded via CDN before the script runs.
- •Content items and visual items are matched strictly by DOM index — keep them in the same order.
- •The isAnimating flag blocks a new switchTab() call while a transition is still running, preventing overlapping animations.
- •Clicking the currently active tab does nothing — the guard if (item === activeContent) return handles this.
- •progressBarTween is killed at the start of every switchTab() call to stop a running tween from triggering a second advance mid-transition.
Guide
Enabling and disabling autoplay
Add data-tabs-autoplay="true" to the wrapper to enable autoplay. Set data-tabs-autoplay-duration to control how long each tab stays active in milliseconds.
<!-- Autoplay enabled, 4 second interval -->
<div data-tabs="wrapper" data-tabs-autoplay="true" data-tabs-autoplay-duration="4000">
<!-- Autoplay disabled -->
<div data-tabs="wrapper">Customising the progress indicator
The startProgressBar() function contains the GSAP tween for the progress element. Replace the scaleX animation with anything — a circle stroke, a counting timer, a filling border — as long as the onComplete callback calls switchTab(nextIndex).
Triggering on scroll entry
Wrap the initial switchTab(0) call in a ScrollTrigger so autoplay only starts when the user scrolls to the section.
ScrollTrigger.create({
trigger: wrapper,
start: "top center",
once: true,
onEnter: () => switchTab(0)
});First-run guard
The outgoing animation block is wrapped in if (outgoingContent) — on the very first call there is no previous tab, so this prevents null-access warnings while still running the incoming animations.