Open demo

CSS code

HTML
<div class="w">
  <nav class="tabs" aria-label="Panels"><a href="#sl1" aria-current="true">Plan</a><a href="#sl2">Build</a><a href="#sl3">Ship</a></nav>
  <div class="track" tabindex="0" role="region" aria-label="Swipeable panels">
    <section class="slide" id="sl1" aria-labelledby="sl1-h"><div class="box" style="background:linear-gradient(140deg,#6366f1,#a855f7)"></div>
      <h3 id="sl1-h">Plan</h3><p>Swipe sideways, or use the tabs. Both move the same scroller, so the indicator can never disagree with what is on screen.</p></section>
    <section class="slide" id="sl2" aria-labelledby="sl2-h"><div class="box" style="background:linear-gradient(140deg,#0ea5e9,#22d3ee)"></div>
      <h3 id="sl2-h">Build</h3><p><code>scroll-snap-type: x mandatory</code> holds each panel in place; there is no JavaScript deciding where a swipe lands.</p></section>
    <section class="slide" id="sl3" aria-labelledby="sl3-h"><div class="box" style="background:linear-gradient(140deg,#16a34a,#84cc16)"></div>
      <h3 id="sl3-h">Ship</h3><p>The track is focusable and labelled, so it can be scrolled with a keyboard rather than only by touch.</p></section>
  </div>
  <p class="hint">The tabs are anchor links, so the browser does the scrolling — smooth behaviour and all.</p>
</div>
CSS
/* Swipeable panels: a scroll-snap track with anchor links above it. The browser scrolls to a panel when a
   link is clicked; a few lines of script move the highlight when a swipe settles on another panel. */
.w{width:100%;max-width:420px;padding:20px 0 22px;overflow:hidden;background:#fff;border:1px solid #e3e8f2;
  border-radius:16px;box-shadow:0 16px 38px -24px rgba(16,24,48,.24)}
.tabs{display:flex;gap:4px;margin:0 20px 14px;background:#f2f5fa;border-radius:11px;padding:4px}
.tabs a{flex:1;text-align:center;padding:8px;border-radius:8px;font-size:.83rem;font-weight:700;color:#5d6a86;text-decoration:none}
.tabs a:hover{color:#16203a}
.tabs a[aria-current=true]{background:#4f46e5;color:#fff}
.tabs a:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.track{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none;gap:16px;padding:0 20px}
.track::-webkit-scrollbar{display:none}
.track:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.slide{flex:0 0 100%;scroll-snap-align:center}
.slide h3{font-size:.95rem;margin:0 0 6px}
.slide p{font-size:.88rem;line-height:1.7;color:#4a5670;margin:0}
.slide .box{border-radius:12px;aspect-ratio:16/9;margin-bottom:12px}
.hint{margin:16px 20px 0;padding-top:13px;border-top:1px solid #eef1f7;font-size:.78rem;color:#5d6a86;line-height:1.6}
@media (prefers-reduced-motion:reduce){.track{scroll-behavior:auto}}
JavaScript
// Move the highlight (aria-current) to the link of the panel in view once the scroller comes to rest.
const track = document.querySelector('.track');
const slides = [...track.children];
const links = [...document.querySelectorAll('.tabs a')];
let settle;
track.addEventListener('scroll', () => {
  clearTimeout(settle);
  settle = setTimeout(() => {
    const i = Math.round(track.scrollLeft / (slides[1].offsetLeft - slides[0].offsetLeft));
    links.forEach((a, j) => j === i ? a.setAttribute('aria-current', 'true') : a.removeAttribute('aria-current'));
  }, 80);
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<div class="w">
  <div class="nav nav-pills tabs" role="tablist" aria-label="Panels">
    <button class="nav-link active" id="sl-plan-tab" data-bs-toggle="pill" data-bs-target="#sl-plan" type="button" role="tab" aria-controls="sl-plan" aria-selected="true">Plan</button>
    <button class="nav-link" id="sl-build-tab" data-bs-toggle="pill" data-bs-target="#sl-build" type="button" role="tab" aria-controls="sl-build" aria-selected="false" tabindex="-1">Build</button>
    <button class="nav-link" id="sl-ship-tab" data-bs-toggle="pill" data-bs-target="#sl-ship" type="button" role="tab" aria-controls="sl-ship" aria-selected="false" tabindex="-1">Ship</button>
  </div>
  <div class="tab-content track" tabindex="0" role="region" aria-label="Swipeable panels">
    <div class="tab-pane active" id="sl-plan" role="tabpanel" aria-labelledby="sl-plan-tab"><div class="box" style="background:linear-gradient(140deg,#6366f1,#a855f7)"></div>
      <h3>Plan</h3><p>Swipe sideways, or use the tabs. Both move the same scroller, so the indicator can never disagree with what is on screen.</p></div>
    <div class="tab-pane" id="sl-build" role="tabpanel" aria-labelledby="sl-build-tab"><div class="box" style="background:linear-gradient(140deg,#0ea5e9,#22d3ee)"></div>
      <h3>Build</h3><p><code>scroll-snap-type: x mandatory</code> holds each panel in place; there is no JavaScript deciding where a swipe lands.</p></div>
    <div class="tab-pane" id="sl-ship" role="tabpanel" aria-labelledby="sl-ship-tab"><div class="box" style="background:linear-gradient(140deg,#16a34a,#84cc16)"></div>
      <h3>Ship</h3><p>The track is focusable and labelled, so it can be scrolled with a keyboard rather than only by touch.</p></div>
  </div>
  <p class="hint">Showing a tab scrolls the track, and scroll snap decides where a swipe lands — smooth behaviour and all.</p>
</div>
CSS
/* Swipeable panels: Bootstrap .nav-pills and the Tab plugin over a scroll-snap .tab-content in which every pane
   stays rendered. Showing a tab scrolls the track; a swipe that settles on a pane shows its tab. */
.w{width:100%;max-width:420px;padding:20px 0 22px;overflow:hidden;background:#fff;border:1px solid #e3e8f2;
  border-radius:16px;box-shadow:0 16px 38px -24px rgba(16,24,48,.24)}
.tabs{--bs-nav-link-padding-x:8px;--bs-nav-link-padding-y:8px;--bs-nav-link-font-size:.83rem;--bs-nav-link-font-weight:700;
  --bs-nav-link-color:#5d6a86;--bs-nav-link-hover-color:#16203a;--bs-nav-pills-border-radius:8px;
  --bs-nav-pills-link-active-bg:#4f46e5;--bs-nav-pills-link-active-color:#fff;
  flex-wrap:nowrap;gap:4px;margin:0 20px 14px;background:#f2f5fa;border-radius:11px;padding:4px}
.tabs .nav-link{flex:1;text-align:center;transition:none}
.tabs .nav-link:focus-visible{box-shadow:none;outline:2px solid #4f46e5;outline-offset:2px}
.track{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none;gap:16px;padding:0 20px}
.track::-webkit-scrollbar{display:none}
.track:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.track>.tab-pane{display:block;flex:0 0 100%;scroll-snap-align:center}
.track h3{font-size:.95rem;font-weight:700;line-height:normal;margin:0 0 6px}
.track p{font-size:.88rem;line-height:1.7;color:#4a5670;margin:0}
.track code{color:inherit;font-size:.9em}
.track .box{border-radius:12px;aspect-ratio:16/9;margin-bottom:12px}
.hint{margin:16px 20px 0;padding-top:13px;border-top:1px solid #eef1f7;font-size:.78rem;color:#5d6a86;line-height:1.6}
@media (prefers-reduced-motion:reduce){.track{scroll-behavior:auto}}
JavaScript
// Tie the Tab plugin to the scroll-snap track: showing a tab scrolls to its pane,
// and when a swipe comes to rest the tab for the pane in view is shown.
const track = document.querySelector('.track');
const tabs = [...document.querySelectorAll('.tabs [data-bs-toggle="pill"]')];
const panes = [...track.children];
const step = () => panes[1].offsetLeft - panes[0].offsetLeft;
tabs.forEach((t, i) => t.addEventListener('show.bs.tab', () => track.scrollTo({ left: i * step() })));
let settle;
track.addEventListener('scroll', () => {
  clearTimeout(settle);
  settle = setTimeout(() => bootstrap.Tab.getOrCreateInstance(tabs[Math.round(track.scrollLeft / step())]).show(), 80);
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="w-full max-w-[420px] overflow-hidden rounded-2xl border border-tab-line bg-white pt-5 pb-[22px] shadow-[0_16px_38px_-24px_rgba(16,24,48,.24)]">
  <div class="mx-5 mb-3.5 flex gap-1 rounded-[11px] bg-tab-soft p-1" role="tablist" aria-label="Panels" data-swipe-tabs>
    <button type="button" role="tab" id="sl-plan-tab" aria-controls="sl-plan" aria-selected="true" class="flex-1 cursor-pointer rounded-lg p-2 text-[.83rem] font-bold text-tab-muted hover:text-tab-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab-accent aria-selected:bg-tab-accent aria-selected:text-white">Plan</button>
    <button type="button" role="tab" id="sl-build-tab" aria-controls="sl-build" aria-selected="false" tabindex="-1" class="flex-1 cursor-pointer rounded-lg p-2 text-[.83rem] font-bold text-tab-muted hover:text-tab-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab-accent aria-selected:bg-tab-accent aria-selected:text-white">Build</button>
    <button type="button" role="tab" id="sl-ship-tab" aria-controls="sl-ship" aria-selected="false" tabindex="-1" class="flex-1 cursor-pointer rounded-lg p-2 text-[.83rem] font-bold text-tab-muted hover:text-tab-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab-accent aria-selected:bg-tab-accent aria-selected:text-white">Ship</button>
  </div>
  <div class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth px-5 [scrollbar-width:none] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tab-accent motion-reduce:scroll-auto [&::-webkit-scrollbar]:hidden" tabindex="0" role="region" aria-label="Swipeable panels" data-swipe-track>
    <div id="sl-plan" role="tabpanel" aria-labelledby="sl-plan-tab" class="shrink-0 basis-full snap-center"><div class="mb-3 aspect-video rounded-xl bg-[linear-gradient(140deg,#6366f1,#a855f7)]"></div>
      <h3 class="mb-1.5 text-[.95rem] font-bold">Plan</h3><p class="text-[.88rem] leading-[1.7] text-tab-body">Swipe sideways, or use the tabs. Both move the same scroller, so the indicator can never disagree with what is on screen.</p></div>
    <div id="sl-build" role="tabpanel" aria-labelledby="sl-build-tab" class="shrink-0 basis-full snap-center"><div class="mb-3 aspect-video rounded-xl bg-[linear-gradient(140deg,#0ea5e9,#22d3ee)]"></div>
      <h3 class="mb-1.5 text-[.95rem] font-bold">Build</h3><p class="text-[.88rem] leading-[1.7] text-tab-body"><code>scroll-snap-type: x mandatory</code> holds each panel in place; there is no JavaScript deciding where a swipe lands.</p></div>
    <div id="sl-ship" role="tabpanel" aria-labelledby="sl-ship-tab" class="shrink-0 basis-full snap-center"><div class="mb-3 aspect-video rounded-xl bg-[linear-gradient(140deg,#16a34a,#84cc16)]"></div>
      <h3 class="mb-1.5 text-[.95rem] font-bold">Ship</h3><p class="text-[.88rem] leading-[1.7] text-tab-body">The track is focusable and labelled, so it can be scrolled with a keyboard rather than only by touch.</p></div>
  </div>
  <p class="mx-5 mt-4 border-t border-[#eef1f7] pt-[13px] text-[.78rem] leading-[1.6] text-tab-muted">Selecting a tab scrolls the track, and scroll snap decides where a swipe lands — smooth behaviour and all.</p>
</div>
CSS
@theme {
  --color-tab-ink: #16203a;
  --color-tab-body: #4a5670;
  --color-tab-muted: #5d6a86;
  --color-tab-line: #e3e8f2;
  --color-tab-soft: #f2f5fa;
  --color-tab-accent: #4f46e5;
}
JavaScript
// WAI-ARIA tabs over a scroll-snap track. Selecting a tab scrolls to its panel; when a swipe comes to rest,
// the tab for the panel in view is selected. Arrow keys and Home/End move between tabs.
const list = document.querySelector('[data-swipe-tabs]');
const track = document.querySelector('[data-swipe-track]');
const tabs = [...list.querySelectorAll('[role=tab]')];
const panels = [...track.children];
const step = () => panels[1].offsetLeft - panels[0].offsetLeft;
const select = (i, scroll) => {
  tabs.forEach((t, j) => { t.setAttribute('aria-selected', i === j); t.tabIndex = i === j ? 0 : -1; });
  if (scroll) track.scrollTo({ left: i * step() });
};
list.addEventListener('click', e => { const t = e.target.closest('[role=tab]'); if (t) select(tabs.indexOf(t), true); });
list.addEventListener('keydown', e => {
  const i = tabs.indexOf(e.target);
  const n = { ArrowLeft: i - 1, ArrowUp: i - 1, ArrowRight: i + 1, ArrowDown: i + 1, Home: 0, End: tabs.length - 1 }[e.key];
  if (n === undefined || i < 0) return;
  e.preventDefault();
  const j = (n + tabs.length) % tabs.length;
  tabs[j].focus();
  select(j, true);
});
let settle;
track.addEventListener('scroll', () => {
  clearTimeout(settle);
  settle = setTimeout(() => select(Math.round(track.scrollLeft / step()), false), 80);
});

About this set

Tabs over a horizontal scroll-snap track, so on a phone you can swipe between panels as well as tap the tab strip. The three panels, Plan, Build and Ship, each have a gradient banner, a heading and a paragraph. Use it for mobile-first onboarding, feature tours or card sets where swiping is the natural gesture. The browser does the hard part: scroll-snap-type x mandatory holds each panel in place and scroll-behavior smooth animates the move, so no script decides where a swipe lands. A few lines of script watch for the scroller to come to rest and then move the highlight to the tab of the panel in view, which keeps the strip in step after a swipe. In the plain CSS version the tabs are anchor links that scroll the track and the highlight is aria-current. The Bootstrap version keeps every .tab-pane rendered inside the track and ties the Tab plugin to the scroll position; the Tailwind version does the same with a small WAI-ARIA tabs script.

What’s included

  • Swipe or tap to change panels
  • Scroll snap, not script, decides where a swipe lands
  • The tab highlight follows the panel in view after a swipe
  • Track is focusable and labelled for keyboard scrolling
  • Smooth scrolling switches off under prefers-reduced-motion

Accessibility

In the plain CSS version the tabs are a nav of anchor links with aria-current on the one in view, not ARIA tabs. In the Bootstrap and Tailwind versions they follow the WAI-ARIA tabs pattern with role tablist, tab and tabpanel, aria-selected, aria-controls, a roving tabindex, arrow keys plus Home and End. In every version the track is a labelled region with tabindex="0" so it can be scrolled with the arrow keys, and focus shows a 2px indigo outline. Smooth scrolling is turned off under prefers-reduced-motion.

Customise it

Change the panel width and gap in .slide and .track (plain CSS and Bootstrap) or basis-full and gap-4 (Tailwind). The active colour is #4f46e5 in .tabs a[aria-current], --bs-nav-pills-link-active-bg, or --color-tab-accent.