Swipe
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'For you', posts: [['Paper cranes, folding on loop', '2.4k likes'], ['A clock face made of type', '1.1k likes']] },
{ label: 'Following', posts: [['Studio notes, week 12', 'posted 2h ago'], ['Three ways to fade a list', 'posted 5h ago']] },
{ label: 'Trending', posts: [['Liquid glass, done in CSS', '#interface'], ['The bounciest checkbox yet', '#motion']] },
];
const W = 260;
const COL = W / TABS.length;
const css = `
.tabs-swipe { width: ${W}px; }
.tabs-swipe__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); box-shadow: inset 0 -1px 0 light-dark(#ebedf0, #232529); }
.tabs-swipe__tab { padding: 9px 0 12px; border: 0; border-radius: 6px; background: none; font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.3s; }
.tabs-swipe__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-swipe__line { position: absolute; bottom: 0; left: 0; width: ${COL - 36}px; height: 2px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); transition: transform 0.45s cubic-bezier(0.25, 1, 0.35, 1); }
.tabs-swipe__view { overflow: hidden; margin-top: 10px; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
.tabs-swipe__strip { display: flex; width: ${W * TABS.length}px; transition: transform 0.45s cubic-bezier(0.25, 1, 0.35, 1); }
/* While a finger is down everything tracks it directly; the glide is only for the snap. */
.tabs-swipe--held .tabs-swipe__view { cursor: grabbing; }
.tabs-swipe--held :is(.tabs-swipe__tab, .tabs-swipe__line, .tabs-swipe__strip) { transition: none; }
.tabs-swipe__panel { display: grid; gap: 8px; width: ${W}px; padding: 0 2px; }
.tabs-swipe__post { display: flex; align-items: center; gap: 10px; }
.tabs-swipe__thumb { width: 38px; height: 38px; flex: none; border-radius: 9px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-swipe__title { display: block; color: light-dark(#42454c, #e5e6e9); font-size: 13px; font-weight: 500; }
.tabs-swipe__meta { display: block; margin-top: 2px; color: light-dark(#5f626a, #a7abb2); font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
.tabs-swipe__tab, .tabs-swipe__line, .tabs-swipe__strip { transition: none; }
}
`;
export function SwipeTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [dx, setDx] = useState(0);
const [held, setHeld] = useState(false);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const drag = useRef<{ x: number; samples: { t: number; x: number }[] } | null>(null);
// Past the first or last panel the drag meets resistance.
const edge = (sel === 0 && dx > 0) || (sel === TABS.length - 1 && dx < 0);
const shift = edge ? dx * 0.3 : dx;
// Where the underline sits, in tabs: it follows the drag continuously.
const at = Math.max(0, Math.min(TABS.length - 1, sel - shift / W));
const end = () => {
const d = drag.current;
drag.current = null;
setHeld(false);
if (!d) return;
const s = d.samples;
const v = s.length > 1 ? (s[s.length - 1].x - s[0].x) / Math.max(1, s[s.length - 1].t - s[0].t) : 0;
// A long drag or a quick flick turns the page; anything else springs back.
const step = dx < -W * 0.25 || v < -0.4 ? 1 : dx > W * 0.25 || v > 0.4 ? -1 : 0;
setSel((i) => Math.max(0, Math.min(TABS.length - 1, i + step)));
setDx(0);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const next = { ArrowRight: Math.min(n - 1, sel + 1), ArrowLeft: Math.max(0, sel - 1), Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className={`tabs-swipe${held ? ' tabs-swipe--held' : ''}`}>
<div className="tabs-swipe__list" role="tablist" aria-label="Feed" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-swipe__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
// Each label brightens as the line approaches it.
style={{ color: `color-mix(in oklab, light-dark(#373a40, #f1f2f3) ${Math.round(Math.max(0, 1 - Math.abs(i - at)) * 100)}%, light-dark(#5f626a, #a7abb2))` }}
>
{t.label}
</button>
))}
<span className="tabs-swipe__line" style={{ transform: `translateX(${at * COL + 18}px)` }} aria-hidden="true" />
</div>
<div
className="tabs-swipe__view"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, samples: [{ t: e.timeStamp, x: e.clientX }] };
setHeld(true);
}}
onPointerMove={(e) => {
const d = drag.current;
if (!d) return;
setDx(e.clientX - d.x);
d.samples.push({ t: e.timeStamp, x: e.clientX });
while (d.samples.length > 2 && e.timeStamp - d.samples[0].t > 100) d.samples.shift();
}}
onPointerUp={end}
onPointerCancel={end}
>
<div className="tabs-swipe__strip" style={{ transform: `translateX(${-sel * W + shift}px)` }}>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-swipe__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} inert={sel !== i}>
{t.posts.map(([title, meta]) => (
<div key={title} className="tabs-swipe__post">
<span className="tabs-swipe__thumb" aria-hidden="true" />
<span>
<span className="tabs-swipe__title">{title}</span>
<span className="tabs-swipe__meta">{meta}</span>
</span>
</div>
))}
</div>
))}
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsSwipe(el) on the mounted element instead.
<div class="tabs-swipe">
<div class="tabs-swipe__list" role="tablist" aria-label="Feed">
<button class="tabs-swipe__tab" type="button" role="tab" id="tabs-swipe-tab-0" aria-selected="true" aria-controls="tabs-swipe-panel-0" tabindex="0" style="color:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#5f626a, #a7abb2))">For you</button>
<button class="tabs-swipe__tab" type="button" role="tab" id="tabs-swipe-tab-1" aria-selected="false" aria-controls="tabs-swipe-panel-1" tabindex="-1" style="color:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#5f626a, #a7abb2))">Following</button>
<button class="tabs-swipe__tab" type="button" role="tab" id="tabs-swipe-tab-2" aria-selected="false" aria-controls="tabs-swipe-panel-2" tabindex="-1" style="color:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#5f626a, #a7abb2))">Trending</button>
<span class="tabs-swipe__line" style="transform:translateX(18px)" aria-hidden="true"></span>
</div>
<div class="tabs-swipe__view">
<div class="tabs-swipe__strip" style="transform:translateX(0px)">
<div class="tabs-swipe__panel" role="tabpanel" id="tabs-swipe-panel-0" aria-labelledby="tabs-swipe-tab-0">
<div class="tabs-swipe__post">
<span class="tabs-swipe__thumb" aria-hidden="true"></span>
<span>
<span class="tabs-swipe__title">Paper cranes, folding on loop</span>
<span class="tabs-swipe__meta">2.4k likes</span>
</span>
</div>
<div class="tabs-swipe__post">
<span class="tabs-swipe__thumb" aria-hidden="true"></span>
<span>
<span class="tabs-swipe__title">A clock face made of type</span>
<span class="tabs-swipe__meta">1.1k likes</span>
</span>
</div>
</div>
<div class="tabs-swipe__panel" role="tabpanel" id="tabs-swipe-panel-1" aria-labelledby="tabs-swipe-tab-1" inert="">
<div class="tabs-swipe__post">
<span class="tabs-swipe__thumb" aria-hidden="true"></span>
<span>
<span class="tabs-swipe__title">Studio notes, week 12</span>
<span class="tabs-swipe__meta">posted 2h ago</span>
</span>
</div>
<div class="tabs-swipe__post">
<span class="tabs-swipe__thumb" aria-hidden="true"></span>
<span>
<span class="tabs-swipe__title">Three ways to fade a list</span>
<span class="tabs-swipe__meta">posted 5h ago</span>
</span>
</div>
</div>
<div class="tabs-swipe__panel" role="tabpanel" id="tabs-swipe-panel-2" aria-labelledby="tabs-swipe-tab-2" inert="">
<div class="tabs-swipe__post">
<span class="tabs-swipe__thumb" aria-hidden="true"></span>
<span>
<span class="tabs-swipe__title">Liquid glass, done in CSS</span>
<span class="tabs-swipe__meta">#interface</span>
</span>
</div>
<div class="tabs-swipe__post">
<span class="tabs-swipe__thumb" aria-hidden="true"></span>
<span>
<span class="tabs-swipe__title">The bounciest checkbox yet</span>
<span class="tabs-swipe__meta">#motion</span>
</span>
</div>
</div>
</div>
</div>
</div>.tabs-swipe { width: 260px; }
.tabs-swipe__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); box-shadow: inset 0 -1px 0 light-dark(#ebedf0, #232529); }
.tabs-swipe__tab { padding: 9px 0 12px; border: 0; border-radius: 6px; background: none; font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.3s; }
.tabs-swipe__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-swipe__line { position: absolute; bottom: 0; left: 0; width: 50.66666666666667px; height: 2px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); transition: transform 0.45s cubic-bezier(0.25, 1, 0.35, 1); }
.tabs-swipe__view { overflow: hidden; margin-top: 10px; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
.tabs-swipe__strip { display: flex; width: 780px; transition: transform 0.45s cubic-bezier(0.25, 1, 0.35, 1); }
/* While a finger is down everything tracks it directly; the glide is only for the snap. */
.tabs-swipe--held .tabs-swipe__view { cursor: grabbing; }
.tabs-swipe--held :is(.tabs-swipe__tab, .tabs-swipe__line, .tabs-swipe__strip) { transition: none; }
.tabs-swipe__panel { display: grid; gap: 8px; width: 260px; padding: 0 2px; }
.tabs-swipe__post { display: flex; align-items: center; gap: 10px; }
.tabs-swipe__thumb { width: 38px; height: 38px; flex: none; border-radius: 9px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-swipe__title { display: block; color: light-dark(#42454c, #e5e6e9); font-size: 13px; font-weight: 500; }
.tabs-swipe__meta { display: block; margin-top: 2px; color: light-dark(#5f626a, #a7abb2); font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
.tabs-swipe__tab, .tabs-swipe__line, .tabs-swipe__strip { transition: none; }
}function initTabsSwipe(root) {
var W = 260;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var line = root.querySelector('.tabs-swipe__line');
var view = root.querySelector('.tabs-swipe__view');
var strip = root.querySelector('.tabs-swipe__strip');
var col = W / tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var dx = 0, drag = null;
function draw() {
// Past the first or last panel the drag meets resistance.
var edge = (sel === 0 && dx > 0) || (sel === tabs.length - 1 && dx < 0);
var shift = edge ? dx * 0.3 : dx;
// Where the underline sits, in tabs: it follows the drag continuously.
var at = Math.max(0, Math.min(tabs.length - 1, sel - shift / W));
strip.style.transform = 'translateX(' + (-sel * W + shift) + 'px)';
line.style.transform = 'translateX(' + (at * col + 18) + 'px)';
// Each label brightens as the line approaches it.
tabs.forEach(function (tab, k) {
tab.style.color = 'color-mix(in oklab, light-dark(#373a40, #f1f2f3) ' + Math.round(Math.max(0, 1 - Math.abs(k - at)) * 100) + '%, light-dark(#5f626a, #a7abb2))';
});
}
function select(i) {
sel = Math.max(0, Math.min(tabs.length - 1, i));
dx = 0;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === sel));
tab.tabIndex = k === sel ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.inert = k !== sel; });
draw();
}
function down(e) {
view.setPointerCapture(e.pointerId);
drag = { x: e.clientX, samples: [{ t: e.timeStamp, x: e.clientX }] };
root.classList.add('tabs-swipe--held');
}
function move(e) {
if (!drag) return;
dx = e.clientX - drag.x;
drag.samples.push({ t: e.timeStamp, x: e.clientX });
while (drag.samples.length > 2 && e.timeStamp - drag.samples[0].t > 100) drag.samples.shift();
draw();
}
function end() {
if (!drag) return;
var s = drag.samples;
drag = null;
root.classList.remove('tabs-swipe--held');
var v = s.length > 1 ? (s[s.length - 1].x - s[0].x) / Math.max(1, s[s.length - 1].t - s[0].t) : 0;
// A long drag or a quick flick turns the page; anything else springs back.
var step = dx < -W * 0.25 || v < -0.4 ? 1 : dx > W * 0.25 || v > 0.4 ? -1 : 0;
select(sel + step);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowRight: Math.min(n - 1, sel + 1), ArrowLeft: Math.max(0, sel - 1), Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
view.addEventListener('pointerdown', down);
view.addEventListener('pointermove', move);
view.addEventListener('pointerup', end);
view.addEventListener('pointercancel', end);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
view.removeEventListener('pointerdown', down);
view.removeEventListener('pointermove', move);
view.removeEventListener('pointerup', end);
view.removeEventListener('pointercancel', end);
};
}
document.querySelectorAll('.tabs-swipe').forEach(initTabsSwipe);Goal: Create a "Swipe" tab set.
Visual behavior: Feed tabs over swipeable panels: drag the panels sideways and the underline tracks your finger between the tabs, snapping to the nearest when you let go.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys (stopping at either end), Home and End move between tabs and select as they go.
- The panels sit side by side in a strip moved by translateX. While dragging, the strip and the underline follow the pointer directly with transitions off, the underline's position in tabs being the selected index minus the drag over the panel width; past either end the drag is damped.
- On release, a drag over a quarter of the width or a quick flick turns the page, and the snap is a transition. Panels off screen are inert, which hides them from assistive tech and the Tab key.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Panels off screen are inert. Under prefers-reduced-motion the panels snap without gliding.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Swipe" tab set.
Visual behavior: Feed tabs over swipeable panels: drag the panels sideways and the underline tracks your finger between the tabs, snapping to the nearest when you let go.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys (stopping at either end), Home and End move between tabs and select as they go.
- The panels sit side by side in a strip moved by translateX. While dragging, the strip and the underline follow the pointer directly with transitions off, the underline's position in tabs being the selected index minus the drag over the panel width; past either end the drag is damped.
- On release, a drag over a quarter of the width or a quick flick turns the page, and the snap is a transition. Panels off screen are inert, which hides them from assistive tech and the Tab key.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Panels off screen are inert. Under prefers-reduced-motion the panels snap without gliding.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Feed tabs over swipeable panels: drag the panels sideways and the underline tracks your finger between the tabs, snapping to the nearest when you let go.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, spring
- Techniques
- event position, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Panels off screen are inert. Under prefers-reduced-motion the panels snap without gliding.