导航滑动指示条
Nav Indicator指示条贴着激活项滑过去,换行时立刻归位
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
布局概览
本周有 3 个动效通过验收,指示条只跟随一次激活变化。
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4(不引入动画库)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现导航滑动指示条:整个导航只有一条绝对定位的指示条,用 offsetLeft/offsetWidth 测量激活标签,并以 260ms 滑过去;首次挂载、窗口缩放后、以及导航换行时都直接落位不播放动画。
文件契约
- 只新建一个文件:components/effects/NavIndicator.tsx。
- 依赖:无需额外依赖:指示条只用原生 CSS transition 与 DOM 测量,不要新增任何包。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";导出 NavIndicator,props 为 items、value、onChange、duration、thickness、glow、ariaLabel、panelId、className,全部可选并带默认值。
- 导航容器 position: relative 且 padding: 0,内层 role="tablist" 负责换行与内边距;指示条是容器的绝对定位子元素,静息位置由 CSS 变量 --ni-left / --ni-width / --ni-top 提供,滑动时才用内联 left / width 覆盖。
- 首次 useLayoutEffect 测量激活标签的 offsetLeft / offsetWidth,直接把值写进 CSS 变量(不挂 transition,不闪到左边);之后每次激活变化先强制 reflow 再设置 transition: left {duration}ms cubic-bezier(0.2, 0.8, 0.2, 1), width … 与目标值;transitionend(propertyName === "left")时把最终值写回 CSS 变量并清空内联 left / width / transition。
- ResizeObserver 观察导航容器,同时监听 window 的 resize:两种回调都只重测并写 CSS 变量,不播放动画;各标签 offsetTop 去重后大于 1 表示已换行,此时切换直接跳位。
- 键盘:role="tablist" + role="tab",roving tabIndex(激活项 0、其余 -1),方向键 / Home / End 切换并 .focus(),Enter/Space 走原生 click;prefers-reduced-motion 用 JS 的 matchMedia 降级为直接跳位,并在组件内 <style> 媒体查询里把过渡压到 0.001ms 兜底。
规格 —— 精确数值,不许四舍五入或“优化”
- 只有一条指示条:放在导航容器里的唯一一个绝对定位元素,不要给每个标签各画一条。
- 几何:读取激活标签的 offsetLeft 与 offsetWidth(和指示条同一个 offsetParent),不要用 getBoundingClientRect 手工扣除父级偏移,也不要用百分比宽度。
- 时长:left 与 width 的过渡为 260ms、cubic-bezier(0.2, 0.8, 0.2, 1),两个属性共用同一时长;不要弹簧、不要 keyframes。
- 首次挂载:在 layout effect 里测量并直接落位,不播放过渡,也不能出现从 left: 0 滑过来的闪动。
- 落定:过渡结束后把最终的 left/width/top 写进 CSS 变量 --ni-left / --ni-width / --ni-top,并清空内联 left、width、transition,静息位置由 CSS 接管。
- 缩放:用 ResizeObserver 观察导航容器,同时监听 window 的 resize;两种回调都重新测量并直接落位,不播放动画。
- 换行:标签分布在多行(offsetTop 出现多个值)时,下一次切换直接跳位,不允许横穿两行。
- 指示条:高 2px,宽度 = 激活标签的实测宽度,颜色 #c9f45a,圆角 2px,上边缘正好贴住标签底边(top = 标签 offsetTop + offsetHeight),pointer-events: none。
- 导航视觉:背景 #12151a,1px rgba(237, 238, 240, 0.08) 边框,圆角 14px,内边距 4px 6px,间距 2px,flex-wrap: wrap。标签:高 32px,左右内边距 10px,圆角 8px,字号 13px,字重 500,未激活 #9a9ca1,激活 #edeef0 + 背景 rgba(201, 244, 90, 0.08)。
- 微光:指示条下方加 0 0 10px rgba(201, 244, 90, 0.55), 0 0 26px rgba(201, 244, 90, 0.22);微光跟着指示条走,本身不做动画。
- 垂直位置可以瞬间跳变(换行、缩放),但只允许 left 与 width 做过渡。
行为
- 状态机(文字状态图):
mount → 测量 → rest(无过渡)。
rest --(激活变化、单行、允许动效)--> sliding --(left 的 transitionend)--> rest(清空内联 left/width,写回 CSS 变量)。
rest --(换行状态下激活变化,或 reduced motion)--> rest(直接跳位,禁用过渡)。
sliding --(容器缩放 / 窗口 resize)--> rest(取消进行中的过渡,直接落位)。
- 传 value 时受控(触发 onChange),否则用 defaultValue 非受控;点击标签即选中,再次点击当前激活项不做任何事。
- 键盘:roving tabindex——按 Tab 只进入激活项。方向键(← → ↑ ↓)把选中与焦点一起移动;Home / End 跳到第一个 / 最后一个;Enter 与 Space 走原生按钮 click 激活。焦点环 2px #C9F45A、外扩 2px,不能被指示条遮住。
- ARIA:内层列表是 role="tablist" 并带 aria-label;每个标签是 role="tab" 的 button,带 aria-selected,传入 panel id 时再带 aria-controls。
- prefers-reduced-motion: reduce:切换立即完成(过渡时长 0.001ms)并直接落在终态,完全不滑动。
- 这个动效不涉及任何网络请求,因此也不得引入或改变请求时序。
- 卸载时断开 ResizeObserver 与监听;不要留下定时器或动画帧。
禁止
- 不要改交互语义、不要改任何标签的可访问名称、不要改请求时序。
- 不要用 requestAnimationFrame 逐帧做动画;唯一允许的运动是一次性的 left/width CSS transition,以及瞬时的重新落位。
- 不要用链式 setTimeout 等动画结束或安排下一步——用 transitionend;被 resize 打断时取消过渡,而不是等计时器。
- 不要动画 height、top、margin、padding 或 box-shadow。
- 不要写死指示条宽度,不要用每个标签各自的元素加 transform 来假装滑动,也不要把指示条放到导航容器外面。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要用 console.log、不要留注释掉的实验代码。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 不要引入 Motion、GSAP 或任何动画库,原生 CSS transition 已经足够。
验收 —— 回答前逐项自查
1. 切换标签时,指示条在 260ms 内滑到新标签正下方,left 与 width 和激活标签精确对齐,缓动为 cubic-bezier(0.2, 0.8, 0.2, 1)。
2. 在滑动开始后 60–80ms 采样:指示条的 left 严格处于起点与终点的值之间,既没有停在旧位置,也没有提前到位。
3. 滑动结束后检查指示条元素:内联 left / width / transition 已全部清空,位置由 CSS 变量 --ni-left / --ni-width 承接,无残留内联样式。
4. 拖动窗口或切换窄屏宽度时,导航容器 boundingBox 稳定不抖动,指示条在同一帧重新对齐激活项(不播放滑动);导航换行成多行时切换直接跳到目标行,不横向穿过两行。
5. prefers-reduced-motion: reduce 时切换立即完成(≤1ms)并直接落在终态,不出现滑动动画。
6. 键盘可完成全部操作:Tab 进入导航后方向键 / Home / End 切换,Enter/Space 确认,焦点环 2px #C9F45A 清晰可见。
7. 控制台 0 报错;首次挂载不播放动画;卸载后 ResizeObserver、resize 与键盘监听全部清理。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type TransitionEvent,
} from "react";
import { NAV_INDICATOR } from "@/content/effects/nav-indicator/constants";
const EASE = NAV_INDICATOR.easing.css;
/**
* Custom properties own the resting geometry, so the bar never keeps a stale
* inline pixel value after the transition ends. The animation itself overrides
* `left` / `width` inline and then hands them back.
*/
const STYLES = `
.fc-nav-indicator-root {
box-sizing: border-box;
}
.fc-nav-indicator-track {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: ${NAV_INDICATOR.nav.gap}px;
padding: ${NAV_INDICATOR.nav.paddingY}px ${NAV_INDICATOR.nav.paddingX}px;
position: relative;
z-index: 1;
}
.fc-nav-indicator-tab {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
height: ${NAV_INDICATOR.nav.itemHeight}px;
padding: 0 ${NAV_INDICATOR.nav.itemPaddingX}px;
border: 0;
border-radius: ${NAV_INDICATOR.nav.itemRadius}px;
background: transparent;
color: ${NAV_INDICATOR.nav.idleColor};
font-family: ${NAV_INDICATOR.nav.fontFamily};
font-size: ${NAV_INDICATOR.nav.fontSize}px;
font-weight: ${NAV_INDICATOR.nav.fontWeight};
letter-spacing: ${NAV_INDICATOR.nav.letterSpacing};
line-height: 1;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color 140ms ease, color 140ms ease;
}
.fc-nav-indicator-tab:hover {
color: ${NAV_INDICATOR.nav.activeColor};
background: ${NAV_INDICATOR.nav.hoverBackground};
}
.fc-nav-indicator-tab[aria-selected="true"] {
color: ${NAV_INDICATOR.nav.activeColor};
background: ${NAV_INDICATOR.nav.activeBackground};
}
.fc-nav-indicator-tab:focus-visible {
outline: 2px solid ${NAV_INDICATOR.nav.focusColor};
outline-offset: ${NAV_INDICATOR.nav.focusOffset}px;
}
.fc-nav-indicator-bar {
left: var(--ni-left, 0px);
width: var(--ni-width, 0px);
top: var(--ni-top, 0px);
}
@media (prefers-reduced-motion: reduce) {
.fc-nav-indicator-bar {
transition-duration: 0.001ms !important;
}
}
`;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return reduced;
}
export type NavIndicatorItem = { id: string; label: string };
export type NavIndicatorProps = {
items?: NavIndicatorItem[];
value?: string;
defaultValue?: string;
onChange?: (id: string) => void;
duration?: number;
thickness?: number;
glow?: boolean;
ariaLabel?: string;
panelId?: string;
className?: string;
};
type Geometry = { left: number; width: number; top: number };
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
export function NavIndicator({
items = NAV_INDICATOR.items,
value,
defaultValue,
onChange,
duration = NAV_INDICATOR.defaults.duration,
thickness = NAV_INDICATOR.defaults.thickness,
glow = NAV_INDICATOR.defaults.glow,
ariaLabel = "Sections",
panelId,
className,
}: NavIndicatorProps) {
const baseId = useId();
const [uncontrolled, setUncontrolled] = useState<string | null>(
defaultValue ?? items[0]?.id ?? null,
);
const requested = value ?? uncontrolled ?? items[0]?.id ?? "";
const active = items.some((item) => item.id === requested)
? requested
: (items[0]?.id ?? "");
const rootRef = useRef<HTMLDivElement | null>(null);
const barRef = useRef<HTMLSpanElement | null>(null);
const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const geometryRef = useRef<Geometry>({ left: 0, width: 0, top: 0 });
const activeRef = useRef(active);
const lastActiveRef = useRef<string | null>(null);
const animatingRef = useRef(false);
const firstMeasureRef = useRef(true);
const reduced = usePrefersReducedMotion();
const measure = useCallback((id: string): Geometry | null => {
const item = itemRefs.current.get(id);
if (!item) return null;
return {
left: item.offsetLeft,
width: item.offsetWidth,
top: item.offsetTop + item.offsetHeight,
};
}, []);
const isWrapped = useCallback((): boolean => {
let rowTop: number | null = null;
let wrapped = false;
itemRefs.current.forEach((node) => {
if (rowTop === null) {
rowTop = node.offsetTop;
return;
}
if (node.offsetTop !== rowTop) wrapped = true;
});
return wrapped;
}, []);
/** park the bar on the measured geometry without animating and drop the inline values */
const settle = useCallback((geometry: Geometry) => {
const bar = barRef.current;
if (!bar) return;
bar.style.setProperty("--ni-left", `${geometry.left}px`);
bar.style.setProperty("--ni-width", `${geometry.width}px`);
bar.style.setProperty("--ni-top", `${geometry.top}px`);
bar.style.left = "";
bar.style.width = "";
bar.style.transition = "";
geometryRef.current = geometry;
animatingRef.current = false;
}, []);
const itemsSignature = items.map((item) => `${item.id}:${item.label}`).join("|");
useIsomorphicLayoutEffect(() => {
const bar = barRef.current;
const geometry = measure(active);
activeRef.current = active;
if (!bar || !geometry) return;
const first = firstMeasureRef.current;
const selectionChanged = lastActiveRef.current !== active;
firstMeasureRef.current = false;
lastActiveRef.current = active;
const from = geometryRef.current;
const moved =
Math.abs(geometry.left - from.left) > 0.5 ||
Math.abs(geometry.width - from.width) > 0.5;
if (first || !selectionChanged || reduced || isWrapped() || !moved) {
settle(geometry);
return;
}
if (animatingRef.current) {
// a second selection landed mid-flight: retarget the running transition
bar.style.setProperty("--ni-top", `${geometry.top}px`);
bar.style.left = `${geometry.left}px`;
bar.style.width = `${geometry.width}px`;
geometryRef.current = geometry;
return;
}
bar.style.transition = "none";
bar.style.left = `${from.left}px`;
bar.style.width = `${from.width}px`;
bar.style.setProperty("--ni-top", `${geometry.top}px`);
// flush the start position so the browser has a "from" value to animate from
void bar.offsetWidth;
bar.style.transition = `left ${duration}ms ${EASE}, width ${duration}ms ${EASE}`;
bar.style.left = `${geometry.left}px`;
bar.style.width = `${geometry.width}px`;
geometryRef.current = geometry;
animatingRef.current = true;
}, [active, duration, reduced, itemsSignature, measure, isWrapped, settle]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const remeasure = () => {
const geometry = measure(activeRef.current);
if (geometry) settle(geometry);
};
const observer = new ResizeObserver(remeasure);
observer.observe(root);
window.addEventListener("resize", remeasure);
return () => {
observer.disconnect();
window.removeEventListener("resize", remeasure);
};
}, [measure, settle]);
const handleTransitionEnd = (event: TransitionEvent<HTMLSpanElement>) => {
if (!animatingRef.current) return;
if (event.propertyName !== "left" && event.propertyName !== "width") return;
settle(geometryRef.current);
};
const select = (id: string) => {
if (id === active) return;
if (value === undefined) setUncontrolled(id);
onChange?.(id);
};
const handleKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
index: number,
) => {
const last = items.length - 1;
let next = -1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
next = index === last ? 0 : index + 1;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
next = index === 0 ? last : index - 1;
} else if (event.key === "Home") {
next = 0;
} else if (event.key === "End") {
next = last;
} else {
return;
}
if (next < 0 || next === index) return;
const nextItem = items[next];
if (!nextItem) return;
event.preventDefault();
select(nextItem.id);
itemRefs.current.get(nextItem.id)?.focus();
};
return (
<div
ref={rootRef}
className={["fc-nav-indicator-root", className]
.filter(Boolean)
.join(" ")}
style={{
position: "relative",
background: NAV_INDICATOR.nav.background,
border: `1px solid ${NAV_INDICATOR.nav.borderColor}`,
borderRadius: NAV_INDICATOR.nav.radius,
}}
>
<style>{STYLES}</style>
<div
className="fc-nav-indicator-track"
role="tablist"
aria-label={ariaLabel}
aria-orientation="horizontal"
>
{items.map((item, index) => {
const isActive = item.id === active;
return (
<button
key={item.id}
ref={(node) => {
if (node) itemRefs.current.set(item.id, node);
else itemRefs.current.delete(item.id);
}}
type="button"
role="tab"
id={`${baseId}-tab-${item.id}`}
aria-selected={isActive}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
className="fc-nav-indicator-tab"
onClick={() => select(item.id)}
onKeyDown={(event) => handleKeyDown(event, index)}
>
{item.label}
</button>
);
})}
</div>
<span
ref={barRef}
aria-hidden="true"
data-nav-indicator="true"
className="fc-nav-indicator-bar"
onTransitionEnd={handleTransitionEnd}
style={{
position: "absolute",
height: thickness,
borderRadius: thickness,
background: NAV_INDICATOR.indicator.color,
boxShadow: glow
? `0 0 ${NAV_INDICATOR.indicator.glowBlurNear}px ${NAV_INDICATOR.indicator.glowNear}, 0 0 ${NAV_INDICATOR.indicator.glowBlurFar}px ${NAV_INDICATOR.indicator.glowFar}`
: "none",
pointerEvents: "none",
zIndex: 2,
}}
/>
</div>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
导航里只有一条绝对定位的指示条,它不随每个标签各画一条,而是每次激活变化时读取激活标签的 `offsetLeft` / `offsetWidth`,用 260ms cubic-bezier(0.2, 0.8, 0.2, 1) 滑过去。首次挂载不做动画;过渡结束后清空内联 `left` / `width`,位置交给 CSS 变量承接,窗口缩放时重新测量而不会被写死的像素拉偏。导航容器用 `ResizeObserver` 观察,窄屏换行时直接跳位,不横穿两行。
- 滑动时长
- 260 ms
- 指示条高度
- 2 px
- 青柠微光
- 开启
- 滑动
- 260ms · cubic-bezier(0.2, 0.8, 0.2, 1)
- 指示条
- 2px 高 · 宽度实测
- 微光
- #c9f45a · 10px
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 切换标签时,指示条在 260ms 内滑到新标签正下方,left 与 width 和激活标签精确对齐,缓动为 cubic-bezier(0.2, 0.8, 0.2, 1)。
- 在滑动开始后 60–80ms 采样:指示条的 left 严格处于起点与终点的值之间,既没有停在旧位置,也没有提前到位。
- 滑动结束后检查指示条元素:内联 `left` / `width` / `transition` 已全部清空,位置由 CSS 变量 `--ni-left` / `--ni-width` 承接,无残留内联样式。
- 拖动窗口或切换窄屏宽度时,导航容器 boundingBox 稳定不抖动,指示条在同一帧重新对齐激活项(不播放滑动);导航换行成多行时切换直接跳到目标行,不横向穿过两行。
- prefers-reduced-motion: reduce 时切换立即完成(≤1ms)并直接落在终态,不出现滑动动画。
- 键盘可完成全部操作:Tab 进入导航后方向键 / Home / End 切换,Enter/Space 确认,焦点环 2px #C9F45A 清晰可见。
- 控制台 0 报错;首次挂载不播放动画;卸载后 ResizeObserver、resize 与键盘监听全部清理。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。