Skip to content

Sliding Tabs

Sliding tabs for React: an underline that stretches to the next tab, leading edge first. Accessible tablist with arrow keys.

  • Micro-interactions
  • SaaS
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Edges come from measured offsets (ResizeObserver), so any label width works.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/sliding-tabs.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/micro-interactions/sliding-tabs.tsx
1/**
2 * SlidingTabs, tabs with a bar under the active one that stretches toward
3 * the new tab: the leading edge arrives first, the trailing edge follows.
4 *
5 * Props: items[] ({ value, label }), value / defaultValue / onChange,
6 * aria-label (required for screen readers).
7 * CSS vars: --fd-st-accent (bar), --fd-st-ink, --fd-st-muted.
8 *
9 * Browser support: all evergreen browsers; edges are placed from measured
10 * offsets (ResizeObserver), so any label width works.
11 * Accessibility: role="tablist" with a roving tabindex, ArrowLeft/Right move
12 * focus and select, Home/End jump. Reduced motion: the bar jumps.
13 */
14import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
15import type { KeyboardEvent } from "react";
16
17const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
18
19export interface TabItem {
20 value: string;
21 label: string;
22}
23
24export interface SlidingTabsProps {
25 items: TabItem[];
26 value?: string;
27 defaultValue?: string;
28 onChange?: (value: string) => void;
29 className?: string;
30 "aria-label"?: string;
31}
32
33export function SlidingTabs({ items, value, defaultValue, onChange, className, "aria-label": label }: SlidingTabsProps) {
34 const [inner, setInner] = useState(defaultValue ?? items[0]?.value);
35 const current = value ?? inner;
36 const list = useRef<HTMLDivElement>(null);
37 const tabs = useRef<Record<string, HTMLButtonElement | null>>({});
38 const [edge, setEdge] = useState<{ l: number; r: number; dir: 1 | -1 } | null>(null);
39
40 const measure = useCallback(() => {
41 const el = tabs.current[current];
42 const box = list.current;
43 if (!el || !box) return;
44 const l = el.offsetLeft;
45 const r = box.clientWidth - (el.offsetLeft + el.offsetWidth);
46 setEdge((old) => ({ l, r, dir: old && l < old.l ? -1 : 1 }));
47 }, [current]);
48
49 useIso(measure, [measure, items]);
50 useEffect(() => {
51 const box = list.current;
52 if (!box || typeof ResizeObserver === "undefined") return;
53 const ro = new ResizeObserver(measure);
54 ro.observe(box);
55 return () => ro.disconnect();
56 }, [measure]);
57
58 const select = (v: string) => {
59 if (value === undefined) setInner(v);
60 onChange?.(v);
61 };
62 const key = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
63 const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
64 let to = -1;
65 if (step) to = (i + step + items.length) % items.length;
66 else if (e.key === "Home") to = 0;
67 else if (e.key === "End") to = items.length - 1;
68 if (to < 0) return;
69 e.preventDefault();
70 const v = items[to].value;
71 select(v);
72 tabs.current[v]?.focus();
73 };
74
75 return (
76 <div className={`fd-st${className ? ` ${className}` : ""}`}>
77 <style>{css}</style>
78 <div ref={list} className="fd-st-list" role="tablist" aria-label={label}>
79 {items.map((it, i) => (
80 <button
81 key={it.value}
82 ref={(n) => {
83 tabs.current[it.value] = n;
84 }}
85 type="button"
86 role="tab"
87 className="fd-st-tab"
88 aria-selected={current === it.value}
89 tabIndex={current === it.value ? 0 : -1}
90 onClick={() => select(it.value)}
91 onKeyDown={(e) => key(e, i)}
92 >
93 {it.label}
94 </button>
95 ))}
96 {edge ? (
97 <span
98 className="fd-st-bar"
99 aria-hidden="true"
100 style={{
101 left: edge.l,
102 right: edge.r,
103 transitionDuration: edge.dir > 0 ? "480ms, 260ms" : "260ms, 480ms",
104 }}
105 />
106 ) : null}
107 </div>
108 </div>
109 );
110}
111
112const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;
113const css = `
114.fd-st,.fd-st *,.fd-st *::before,.fd-st *::after{box-sizing:border-box}
115.fd-st{--fd-st-accent:#111;--fd-st-ink:#111;--fd-st-muted:rgba(0,0,0,.45);display:inline-block;margin:0;padding:0;font:400 15px/1.2 ${FONT}}
116.fd-st-list{position:relative;display:flex;gap:6px;margin:0;padding:0 0 2px;border-bottom:1px solid rgba(0,0,0,.1)}
117.fd-st-tab{display:block;margin:0;padding:10px 14px 12px;border:0;border-radius:10px 10px 0 0;background:transparent;color:var(--fd-st-muted);font:500 15px/1.2 ${FONT};letter-spacing:normal;cursor:pointer;appearance:none;-webkit-appearance:none;transition:color 240ms;-webkit-tap-highlight-color:transparent}
118.fd-st-tab:hover{color:rgba(0,0,0,.72)}
119.fd-st-tab[aria-selected="true"]{color:var(--fd-st-ink)}
120.fd-st-tab:focus-visible{outline:2px solid #1a73e8;outline-offset:-2px}
121.fd-st-bar{position:absolute;bottom:-1px;display:block;height:2px;margin:0;padding:0;border-radius:2px;background:var(--fd-st-accent);transition-property:left,right;transition-timing-function:cubic-bezier(0.22,1,0.36,1)}
122@media (prefers-reduced-motion:reduce){.fd-st-bar{transition:none!important}}
123`;
124
125export function Demo() {
126 return (
127 <div className="fd-std">
128 <style>{demoCss}</style>
129 <SlidingTabs
130 aria-label="Sections"
131 defaultValue="activity"
132 items={[
133 { value: "overview", label: "Overview" },
134 { value: "activity", label: "Activity" },
135 { value: "files", label: "Files" },
136 { value: "settings", label: "Settings" },
137 ]}
138 />
139 </div>
140 );
141}
142
143const demoCss = `
144.fd-std,.fd-std *,.fd-std *::before,.fd-std *::after{box-sizing:border-box}
145.fd-std{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:#ececec;font:400 15px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}
146`;

More Micro-interactions. Same file contract.

Card Stack, micro interactions preview: Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward

Card Stack

Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward. No dependencies.

Photo Sleeves, micro interactions preview: Photo album sleeves for React: pictures peek out, lift on hover and fan open on click

Photo Sleeves

Photo album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies.

Tilt Card, micro interactions preview: 3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo

Tilt Card

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library.

Questions about Sliding Tabs

Run npx shadcn@latest add https://freedesignmd.com/r/sliding-tabs.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Sliding tabs for React: an underline that stretches to the next tab, leading edge first. Accessible tablist with arrow keys. It is a free, MIT-licensed React micro-interactions component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. Edges come from measured offsets (ResizeObserver), so any label width works.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.