Sliding Tab Indicator
A single highlight slides and resizes between tabs instead of each tab lighting up on its own.
Build accessible React tabs with one sliding indicator. Render a role="tablist" with buttons (role="tab", aria-selected) for "Overview", "Usage" and "API reference". Keep a single absolutely positioned indicator element behind the tabs. When the active tab changes, measure its offsetLeft and offsetWidth and set the indicator with transform: translateX(...) and a scaleX based on width (or width via a CSS variable), transitioning over 260ms with cubic-bezier(0.16, 1, 0.3, 1). Support Left and Right arrow keys to move between tabs. Re-measure on resize. Under prefers-reduced-motion, move the indicator instantly.
Source
'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import s from './demo.module.css'
export default function SlidingTabs({ labels }: { labels: Record<string, string> }) {
const tabs = labels.tabs.split('|')
const [active, setActive] = useState(0)
const list = useRef<HTMLDivElement>(null)
const indicator = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
const place = () => {
const tab = list.current?.children[active + 1] as HTMLElement | undefined
if (!tab || !indicator.current) return
indicator.current.style.width = `${tab.offsetWidth}px`
indicator.current.style.transform = `translateX(${tab.offsetLeft}px)`
}
place()
window.addEventListener('resize', place)
return () => window.removeEventListener('resize', place)
}, [active, labels.tabs])
const onKey = (e: React.KeyboardEvent) => {
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
if (!step) return
const next = (active + step + tabs.length) % tabs.length
setActive(next)
;(list.current?.children[next + 1] as HTMLElement | undefined)?.focus()
}
return (
<div ref={list} className={s.list} role="tablist" onKeyDown={onKey}>
<span ref={indicator} className={s.indicator} aria-hidden="true" />
{tabs.map((t, i) => (
<button
key={t}
type="button"
role="tab"
aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
className={s.tab}
onClick={() => setActive(i)}
>
{t}
</button>
))}
</div>
)
}
.list {
position: relative;
display: inline-flex;
padding: 4px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-md);
background: var(--stage-surface);
}
.indicator {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
border-radius: var(--radius-sm);
background: var(--stage-ink);
transition: transform 260ms var(--ease-out), width 260ms var(--ease-out);
}
.tab {
position: relative;
height: 36px;
padding: 0 14px;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--stage-muted);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
transition: color 260ms var(--ease-out);
}
.tab[aria-selected='true'] { color: var(--stage-bg); }
.tab:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.indicator, .tab { transition: none; }
}