滑动标签指示器
一块高亮在标签之间滑动并改变宽度,而不是每个标签各自亮起。
用 React 制作一个带单个滑动指示器、符合无障碍规范的标签组件。渲染一个 role="tablist",包含“概览”“用法”“API 参考”三个按钮(role="tab",aria-selected)。在标签后方保留一个绝对定位的指示器元素。当前标签改变时,测量它的 offsetLeft 和 offsetWidth,用 transform: translateX(...) 和基于宽度的 scaleX(或通过 CSS 变量设置宽度)移动指示器,过渡时间 260ms,缓动为 cubic-bezier(0.16, 1, 0.3, 1)。支持用左右方向键切换标签。窗口尺寸变化时重新测量。在 prefers-reduced-motion 下让指示器直接跳到目标位置。
源代码
'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; }
}