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
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 toward3 * 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 measured10 * offsets (ResizeObserver), so any label width works.11 * Accessibility: role="tablist" with a roving tabindex, ArrowLeft/Right move12 * 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 <button81 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 <span98 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 <SlidingTabs130 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.
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.


