"use client";
import * as React from "react";
export interface SwipeTab {
/** Unique id; used as the value. */
id: string;
/** Tab label. */
label: string;
/** Optional icon before the label. */
icon?: React.ReactNode;
/** Panel content. Each panel scrolls vertically on its own. */
content: React.ReactNode;
}
export interface SwipeTabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> {
tabs: SwipeTab[];
/** Selected tab id (controlled). */
value?: string;
/** Initially selected tab id (uncontrolled). Defaults to the first tab. */
defaultValue?: string;
/** Called when a tab is chosen or a swipe settles on a new panel. */
onValueChange?: (id: string) => void;
/** Accessible name of the tab list. */
label?: string;
/** Indicator style. */
indicator?: "line" | "pill";
/** The indicator stretches mid-swipe and contracts as it lands. */
stretch?: boolean;
/** Allow swiping between panels. When false, only the tabs switch panels. */
swipeable?: boolean;
/** Indicator and focus colour. */
accent?: string;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/**
* Mobile-style tabs whose panels sit side by side in a native scroll-snap track. The indicator follows
* the swipe continuously: scroll position is written to CSS custom properties on every frame (no React
* renders), and the selection only changes once the scroll settles. Tapping a tab scrolls to its panel;
* inactive panels are inert once settled.
*/
export function SwipeTabs({
tabs,
value,
defaultValue,
onValueChange,
label = "Sections",
indicator = "line",
stretch = true,
swipeable = true,
accent = "#FF7448",
className = "",
style,
...props
}: SwipeTabsProps) {
const id = React.useId();
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const mountedRef = React.useRef(false);
const [internal, setInternal] = React.useState(defaultValue ?? tabs[0]?.id);
const selected = value ?? internal;
const index = Math.max(0, tabs.findIndex((tab) => tab.id === selected));
const count = tabs.length;
const select = (next: string) => {
if (next === selected) return;
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
/* ------------------------- scroll-linked indicator ------------------------- */
// Writes the fractional position to CSS variables. React never sets --sw-p itself (it sets the
// fallback --sw-i), so a re-render mid-swipe cannot snap the indicator back.
const writeProgress = React.useCallback(
(p: number) => {
const root = rootRef.current;
if (!root) return;
const frac = p - Math.floor(p);
root.style.setProperty("--sw-p", p.toFixed(4));
root.style.setProperty("--sw-s", stretch ? (Math.sin(frac * Math.PI) * 0.6).toFixed(4) : "0");
tabRefs.current.forEach((tab, i) => tab?.style.setProperty("--sw-near", clamp(1 - Math.abs(p - i), 0, 1).toFixed(3)));
},
[stretch],
);
const settle = React.useEffectEvent((settledIndex: number) => {
const tab = tabs[settledIndex];
if (tab) select(tab.id);
});
React.useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
let frame = 0;
let timer = 0;
const position = () => (scroller.clientWidth ? scroller.scrollLeft / scroller.clientWidth : 0);
const finish = () => {
window.clearTimeout(timer);
timer = 0;
settle(clamp(Math.round(position()), 0, count - 1));
};
const onScroll = () => {
if (!frame)
frame = requestAnimationFrame(() => {
frame = 0;
writeProgress(clamp(position(), 0, count - 1));
});
// Fallback for browsers without "scrollend": settle shortly after the last scroll event.
window.clearTimeout(timer);
timer = window.setTimeout(finish, 140);
};
writeProgress(clamp(position(), 0, count - 1));
scroller.addEventListener("scroll", onScroll, { passive: true });
scroller.addEventListener("scrollend", finish);
return () => {
scroller.removeEventListener("scroll", onScroll);
scroller.removeEventListener("scrollend", finish);
cancelAnimationFrame(frame);
window.clearTimeout(timer);
};
}, [count, writeProgress]);
// Bring the selected panel into view when the value changes (tab tap, keyboard, controlled update).
const syncScroll = React.useEffectEvent((instant: boolean) => {
const scroller = scrollerRef.current;
if (!scroller || !scroller.clientWidth) return;
const target = index * scroller.clientWidth;
if (Math.abs(scroller.scrollLeft - target) < 1) return;
scroller.scrollTo({ left: target, behavior: instant || reduceMotion ? "auto" : "smooth" });
});
React.useEffect(() => {
syncScroll(!mountedRef.current);
mountedRef.current = true;
}, [index]);
// Keep the selected panel aligned when the track is resized.
React.useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const ro = new ResizeObserver(() => syncScroll(true));
ro.observe(scroller);
return () => ro.disconnect();
}, []);
/* -------------------------------- keyboard -------------------------------- */
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const keys: Record<string, number> = {
ArrowRight: (index + 1) % count,
ArrowLeft: (index - 1 + count) % count,
Home: 0,
End: count - 1,
};
const next = keys[event.key];
if (next === undefined) return;
event.preventDefault();
tabRefs.current[next]?.focus();
select(tabs[next].id);
};
/* ---------------------------------- render --------------------------------- */
const pill = indicator === "pill";
const ring =
"outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sw-accent)]";
return (
<div
ref={rootRef}
className={`flex min-h-0 flex-col [--sw-off:var(--color-zinc-500)] [--sw-on:var(--color-zinc-950)] dark:[--sw-off:var(--color-zinc-400)] dark:[--sw-on:var(--color-white)] ${className}`}
style={{ ["--sw-accent" as string]: accent, ["--sw-i" as string]: index, ...style }}
{...props}
>
<div className={pill ? "shrink-0 px-3 pb-2 pt-1" : "shrink-0 border-b border-zinc-900/10 dark:border-white/10"}>
<div className={pill ? "rounded-full bg-zinc-900/[0.06] p-1 dark:bg-white/[0.07]" : ""}>
<div
role="tablist"
aria-label={label}
onKeyDown={handleKeyDown}
className="relative grid"
style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
>
<span
aria-hidden="true"
className={`pointer-events-none absolute left-0 ${pill ? "inset-y-0" : "bottom-0 h-[2px]"}`}
style={{ width: `${(100 / Math.max(count, 1)).toFixed(4)}%`, translate: "calc(var(--sw-p, var(--sw-i)) * 100%) 0" }}
>
<span
className={
pill
? "absolute inset-0 rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)] dark:bg-zinc-700 dark:shadow-[0_1px_2px_rgba(0,0,0,0.5)]"
: "absolute inset-y-0 left-1/2 w-[46%] -translate-x-1/2 rounded-full bg-[var(--sw-accent)]"
}
style={{ scale: "calc(1 + var(--sw-s, 0)) 1" }}
/>
</span>
{tabs.map((tab, i) => {
const isSelected = i === index;
return (
<button
key={tab.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={isSelected}
aria-controls={`${id}-panel-${i}`}
tabIndex={isSelected ? 0 : -1}
onClick={() => select(tab.id)}
className={`relative flex min-w-0 items-center justify-center gap-1.5 text-[13px] font-medium tracking-tight ${
pill ? "h-8 rounded-full" : "h-11"
} ${ring}`}
style={
{
"--sw-sel": isSelected ? 1 : 0,
color: "color-mix(in oklab, var(--sw-on) calc(var(--sw-near, var(--sw-sel)) * 100%), var(--sw-off))",
} as React.CSSProperties
}
>
{tab.icon && (
<span aria-hidden="true" className="inline-flex shrink-0 [&_svg]:size-4">
{tab.icon}
</span>
)}
<span className="truncate">{tab.label}</span>
</button>
);
})}
</div>
</div>
</div>
<div
ref={scrollerRef}
className={`flex min-h-0 flex-1 snap-x snap-mandatory overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${
swipeable ? "overflow-x-auto" : "overflow-x-hidden"
} overflow-y-hidden`}
>
{tabs.map((tab, i) => {
const isSelected = i === index;
return (
<div
key={tab.id}
role="tabpanel"
id={`${id}-panel-${i}`}
aria-labelledby={`${id}-tab-${i}`}
// Inactive panels stay rendered (so they can be swiped into view) but are inert.
inert={!isSelected}
tabIndex={0}
className={`h-full w-full shrink-0 snap-start snap-always overflow-y-auto overscroll-y-contain ${ring}`}
>
{tab.content}
</div>
);
})}
</div>
</div>
);
}