返回效果库

导航滑动指示条

Nav Indicator

指示条贴着激活项滑过去,换行时立刻归位

完整版:适合从零新建组件,规格、契约、验收全都在。

实时预览

布局
导航视图260ms · 2px

概览

本周有 3 个动效通过验收,指示条只跟随一次激活变化。

方向键切换视图 · Enter 确认 · 首次挂载直接落位
调整参数观察布局动效检测到「减少动态效果」,动效已降级

参数

边调边看,提示词同步更新。

角色

你是一名资深前端工程师,工作在现有的项目中,技术栈是 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 ✓,未通过的项用 ✗ 并附一句原因。

与演示同源生成4,318 字符 · 1,199 约 tokens · 67 行
技术规格

导航里只有一条绝对定位的指示条,它不随每个标签各画一条,而是每次激活变化时读取激活标签的 `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 在输出末尾逐条自检。

  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 与键盘监听全部清理。

待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。

提示词如何还原这个效果

不是把效果描写一遍,而是把效果写成一份可执行的工程需求。

数字规格
每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
文件契约
只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
验收清单
提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。
完整版 / 中文

React + Tailwind