返回效果库

圆形主题揭示

Theme Reveal

从点击处扩散的圆形明暗切换

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

实时预览

布局
feelcurve.dev/themeLAYOUT

布局 / 主题切换

把主题画成一个圆

点击下方按钮:新主题从指针位置扩散,旧画面在圆外保持静止。

暗色
View Transitions: 检测中…350ms · ease-out起点:点击处
调整参数观察布局动效检测到「减少动态效果」,动效已降级

参数

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

角色

你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。

任务

实现圆形主题揭示:一次点击在明暗之间切换,新主题以圆形 clip-path 从点击位置扩散,350ms、ease-out;动画作用在 ::view-transition-new(root) 的快照上,旧画面保持静止。浏览器不支持 View Transitions 时直接切换,可选 120ms 淡入。组件默认作用于 <html>,也接受 target 元素与 onThemeChange 回调,因此可以只切换某个舞台。

文件契约

- 只新建一个文件:components/effects/ThemeReveal.tsx。

- 依赖:无。View Transitions 与 Web Animations API 都是浏览器原生能力,不需要任何依赖。

- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其它现有文件。

- 组件必须可以直接复制:SPEC 里的数值全部内联,不要 import 项目 content/ 目录里的任何内容。

实现要点

- 文件顶部加 "use client";flushSync 从 react-dom 引入。

- 点击时算起点:event.detail > 0 用 clientX / clientY;键盘触发(detail === 0)用按钮中心;fromClick 为 false 时用目标(默认是整个视口)中心。

- 先检测 document.startViewTransition;支持时执行 document.startViewTransition(() => { flushSync(() => 应用主题); }),data-theme 与 localStorage 必须在这个回调里同步写完,否则新快照还是旧主题。

- transition.ready 之后用 document.documentElement.animate(..., { pseudoElement: "::view-transition-new(root)" }) 把 clip-path 从 circle(0px at Xpx Ypx) 动画到 circle(Rpx at Xpx Ypx),时长与缓动来自 props。

- 在 <style> 里写 ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; },否则旧页面会跟着淡出;要把揭示限制在某个容器时,给容器加 view-transition-name 并动画 ::view-transition-new(<name>)。

- 保存当前 ViewTransition:连点时先 skipTransition() 再启动新的;卸载时同样取消。prefers-reduced-motion: reduce 时直接应用最终主题(≤1ms),不做圆形扩散也不做淡入。

规格 —— 精确数值,不许四舍五入或“优化”

- 主题在 light 与 dark 之间切换。把主题写进目标元素的 data-theme 属性(默认是 <html>),并把同一个值同步到 localStorage 的 theme-reveal 键。这两次写入必须和 DOM 更新在同一个回调里完成。

- 揭示时长:350ms,缓动 ease-out。不许换曲线、不许用弹簧或回弹,也不许在同一帧再跑第二个动画。

- 起点:fromClick: true 时圆从点击位置开始;键盘触发的点击没有坐标,改用按钮中心。fromClick: false 时从切换目标(默认整个视口)的中心开始。

- 半径:R = hypot(max(X, W − X), max(Y, H − Y)),X/Y 是起点,W/H 是快照盒尺寸(<html> 用视口,其它目标用它的 rect)。最终圆必须覆盖每个角。

- 支持路径:调用 document.startViewTransition(() => { flushSync(() => applyTheme(next)); }),等 transition.ready 之后用 document.documentElement.animate(..., { duration, easing, pseudoElement }) 动画 ::view-transition-new(root)(传入 target 时是 ::view-transition-new(<name>)),clip-path 从 circle(0px at Xpx Ypx) 到 circle(Rpx at Xpx Ypx)。

- 旧画面:加上 ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }(命名元素同理)。圆之外的旧页面必须完全静止——不许淡出、缩放或模糊。

- 快速连点:保存当前 ViewTransition,下一次点击先 skipTransition() 再启动新的。任何时刻只允许一个过渡。

- 降级:浏览器没有 document.startViewTransition 时直接切换主题;只有 fade 为 true 时做 120ms 的透明度淡入(从 0.55 到 1)。不许伪造扩散,也不许盖一层遮罩。

- prefers-reduced-motion: reduce:完全跳过 View Transition 与淡入,主题在一帧内(≤1ms)到达最终状态。

- 唯一被动画的属性是 clip-path(以及降级时的 opacity)。不要动画 width、height、top、left、transform、filter,也不要克隆或复制页面 DOM。

行为

- 静止:一个带可见文字的真正的 <button>;没有定时器,也没有正在运行的过渡。

- 点击:从目标读出当前主题,算出下一个主题与起点,然后只分一次支——reduced motion 或没有 View Transitions 走直接切换,其余走快照路径。

- 快照路径:启动 view transition,在它的更新回调里提交主题(这样新快照已经是新主题),旧画面保持静止。ready 之后圆按配置的时长用 clip-path 扩散,结束后过渡收尾并移除所有临时样式。

- 连点:先用 skipTransition() 取消正在跑的过渡,再启动且只启动一个新的;最终状态对应最后一次点击。

- 切换失败(卸载、目标被移除、回调抛错):取消过渡、移除临时 view-transition-name,保留最后一次提交的主题。

- 键盘:Tab 能聚焦按钮,Enter 与 Space 都能触发;可见的 2px 焦点环要盖在快照层之上。

- 在 useEffect 的卸载回调里清理:递增 sequence 守卫、调用 skipTransition(),并清掉临时的行内 view-transition-name。

禁止

- 不要改变交互语义:一次点击只切换一次主题;不许跳转、不许额外切换、不许自动播放揭示。

- 不要动画任何元素的 width、height、top、left、margin、transform、filter、box-shadow;揭示只允许用 clip-path。

- 不要用链式 setTimeout / setInterval / requestAnimationFrame 拼效果,也不要把主题写入推迟到过渡回调之后。

- 不要用固定遮罩、克隆 DOM、覆盖整个页面的额外元素或跨文档 View Transition API 伪造揭示。

- 不要新增依赖、不要改 package.json、全局 CSS 或 Tailwind 配置;除了主题值外不要持久化任何东西(传入 target 时绝不能写整个文档的主题)。

- 不要留 TODO 注释、any、console.log,也不要出现与规格不一致的数值。

- 不要用 Motion、Framer Motion、GSAP 或任何动画库:View Transitions 与 Web Animations API 都是原生的。

- 不要用 React 重渲染或克隆树来伪造页面冻结;DOM 只在过渡回调里更新一次。

验收 —— 回答前逐项自查

1. 参数与实现一致:props 默认值必须等于本提示词 SPEC 段里的数值(时长、起点与降级淡入),圆形扩散在 200–700ms 之间可调、起点跟随 fromClick、fade 只在没有 View Transitions 时生效;数值不许四舍五入。

2. 支持 View Transitions 的浏览器里,点击后 60–80ms 采样:新主题的圆半径严格大于 0 且小于最终半径,旧画面在圆外完全静止(没有淡出、位移或模糊)。

3. 动画结束时 ::view-transition-new 的 clip-path 已到最大半径;页面没有残留的行内 clip-path、transform、view-transition-name 等临时样式(组件自带的 ::view-transition-* 规则 <style> 必须保留)。

4. 切换前后 <html> 与演示舞台的 boundingBox 完全不变,不出现横向滚动条、布局跳动或滚动位置变化。

5. prefers-reduced-motion: reduce 下不出现圆形扩散与淡入,主题在 ≤1ms 内直接到达最终状态。

6. 键盘 Tab 能聚焦切换按钮并看到 2px 焦点环,Enter 与 Space 都能完成切换;焦点环不被揭示动画遮挡。

7. 零控制台报错;快速连点不会叠加两个过渡(旧的被 skipTransition() 取消);浏览器不支持 View Transitions 时直接切换,不出现半透明残留。

输出

- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。

- 如果缺少必要依赖,先停下来问我,不要擅自安装。

- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。

与演示同源生成4,659 字符 · 1,294 约 tokens · 64 行
技术规格

点击按钮,新主题从指针位置长成一个圆,用 clip-path 铺满整个页面:旧画面在圆外保持静止,DOM 不复用也不复制。350ms 的 ease-out 由 View Transitions API 的 ::view-transition-new(root) 与 Web Animations API 驱动;不支持 View Transitions 的浏览器直接切换,可选的 120ms 淡入作为降级。组件默认把主题写回 <html data-theme> 与 localStorage,也接受自定义 target 与回调,因此可以只在一个舞台里切换明暗。

揭示时长
350 ms
从点击处扩散
开启
降级淡入
开启
扩散半径
hypot(max(x, W − x), max(y, H − y))
时长 / 缓动
350 ms · ease-out
验收清单

提示词要求 AI 在输出末尾逐条自检。

  1. 参数与实现一致:props 默认值必须等于本提示词 SPEC 段里的数值(时长、起点与降级淡入),圆形扩散在 200–700ms 之间可调、起点跟随 `fromClick`、`fade` 只在没有 View Transitions 时生效;数值不许四舍五入。
  2. 支持 View Transitions 的浏览器里,点击后 60–80ms 采样:新主题的圆半径严格大于 0 且小于最终半径,旧画面在圆外完全静止(没有淡出、位移或模糊)。
  3. 动画结束时 `::view-transition-new` 的 `clip-path` 已到最大半径;页面没有残留的行内 `clip-path`、`transform`、`view-transition-name` 等临时样式(组件自带的 `::view-transition-*` 规则 `<style>` 必须保留)。
  4. 切换前后 `<html>` 与演示舞台的 boundingBox 完全不变,不出现横向滚动条、布局跳动或滚动位置变化。
  5. prefers-reduced-motion: reduce 下不出现圆形扩散与淡入,主题在 ≤1ms 内直接到达最终状态。
  6. 键盘 Tab 能聚焦切换按钮并看到 2px 焦点环,Enter 与 Space 都能完成切换;焦点环不被揭示动画遮挡。
  7. 零控制台报错;快速连点不会叠加两个过渡(旧的被 `skipTransition()` 取消);浏览器不支持 View Transitions 时直接切换,不出现半透明残留。

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

提示词如何还原这个效果

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

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

React + Tailwind