圆形主题揭示
Theme Reveal从点击处扩散的圆形明暗切换
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
布局布局 / 主题切换
把主题画成一个圆
点击下方按钮:新主题从指针位置扩散,旧画面在圆外保持静止。
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 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 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import {
THEME_REVEAL as C,
type ThemeRevealTheme,
} from "@/content/effects/theme-reveal/constants";
type Point = { x: number; y: number };
type Surface = {
element: HTMLElement;
document: Document;
root: boolean;
/** size of the snapshot box in the coordinates clip-path uses */
width: number;
height: number;
left: number;
top: number;
viewTransitionName: string | null;
};
/** view-transition-name must be a safe CSS identifier when it lands in a <style> tag */
const TRANSITION_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
function resolveTheme(
element: HTMLElement,
fallback: ThemeRevealTheme,
): ThemeRevealTheme {
const stored = element.dataset.theme;
return stored === C.themes.light || stored === C.themes.dark
? stored
: fallback;
}
function measureSurface(
target: HTMLElement | null | undefined,
viewTransitionName: string | null,
): Surface {
const doc = target?.ownerDocument ?? document;
const root = !target || target === doc.documentElement;
if (root) {
const view = doc.defaultView;
const width = view?.innerWidth || doc.documentElement.clientWidth || 1;
const height = view?.innerHeight || doc.documentElement.clientHeight || 1;
return {
element: doc.documentElement,
document: doc,
root: true,
width,
height,
left: 0,
top: 0,
viewTransitionName: null,
};
}
const rect = target.getBoundingClientRect();
return {
element: target,
document: doc,
root: false,
width: Math.max(rect.width, 1),
height: Math.max(rect.height, 1),
left: rect.left,
top: rect.top,
viewTransitionName,
};
}
function localOrigin(surface: Surface, click: Point | null): Point {
if (!click) {
return { x: surface.width / 2, y: surface.height / 2 };
}
if (surface.root) {
return {
x: clamp(click.x, 0, surface.width),
y: clamp(click.y, 0, surface.height),
};
}
return {
x: clamp(click.x - surface.left, 0, surface.width),
y: clamp(click.y - surface.top, 0, surface.height),
};
}
/** radius of the circle that reaches the farthest corner of the snapshot box */
function revealRadius(surface: Surface, origin: Point): number {
const dx = Math.max(origin.x, surface.width - origin.x);
const dy = Math.max(origin.y, surface.height - origin.y);
return Math.hypot(dx, dy);
}
export type ThemeRevealProps = {
/** reveal duration in ms (200–700) */
duration?: number;
/** start the circle at the click point instead of the surface centre */
fromClick?: boolean;
/** 120ms opacity fade when View Transitions is unavailable */
fade?: boolean;
/** element whose data-theme flips; defaults to <html> */
target?: HTMLElement | null;
/** localStorage key for the theme value; pass "" to skip persistence */
storageKey?: string;
/** scope the reveal to target by giving its snapshot a view-transition-name */
viewTransitionName?: string;
label?: string;
className?: string;
onThemeChange?: (theme: ThemeRevealTheme) => void;
};
export function ThemeReveal({
duration = C.defaults.duration,
fromClick = C.defaults.fromClick,
fade = C.defaults.fade,
target = null,
storageKey = C.themes.storageKey,
viewTransitionName,
label = "Toggle theme",
className,
onThemeChange,
}: ThemeRevealProps) {
const buttonRef = useRef<HTMLButtonElement | null>(null);
const transitionRef = useRef<ViewTransition | null>(null);
const sequenceRef = useRef(0);
const scopedElementRef = useRef<HTMLElement | null>(null);
/** The theme the next click should start from, including a pending transition. */
const pendingThemeRef = useRef<ThemeRevealTheme | null>(null);
const [theme, setTheme] = useState<ThemeRevealTheme>(C.themes.dark);
const safeViewTransitionName =
viewTransitionName && TRANSITION_NAME_PATTERN.test(viewTransitionName)
? viewTransitionName
: null;
const scopedName =
safeViewTransitionName && target ? safeViewTransitionName : null;
const transitionCss = scopedName
? `::view-transition-old(${scopedName}), ::view-transition-new(${scopedName}) { animation: none; mix-blend-mode: normal; }\n::view-transition-old(root), ::view-transition-new(root) { animation: none; }`
: `::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }`;
// Keep the visible icon in sync with whatever theme the target already has.
useEffect(() => {
const element = target ?? document.documentElement;
const resolved = resolveTheme(element, C.themes.dark);
pendingThemeRef.current = resolved;
setTheme(resolved);
}, [target]);
const releaseScopedElement = useCallback(() => {
const element = scopedElementRef.current;
if (!element) return;
scopedElementRef.current = null;
if (element.style.viewTransitionName === scopedName) {
element.style.viewTransitionName = "";
}
}, [scopedName]);
const stopTransition = useCallback(() => {
sequenceRef.current += 1;
const pending = transitionRef.current;
transitionRef.current = null;
if (pending) {
try {
pending.skipTransition();
} catch {
/* the transition may already be gone */
}
}
releaseScopedElement();
}, [releaseScopedElement]);
// Never leave a running transition or a temporary view-transition-name behind.
useEffect(() => {
return () => {
sequenceRef.current += 1;
const pending = transitionRef.current;
transitionRef.current = null;
if (pending) {
try {
pending.skipTransition();
} catch {
/* the transition may already be gone */
}
}
const element = scopedElementRef.current;
scopedElementRef.current = null;
if (element) element.style.viewTransitionName = "";
};
}, []);
const commitTheme = useCallback(
(element: HTMLElement, next: ThemeRevealTheme) => {
const doc = element.ownerDocument;
element.dataset.theme = next;
element.style.colorScheme = next;
pendingThemeRef.current = next;
if (storageKey) {
try {
doc.defaultView?.localStorage.setItem(storageKey, next);
} catch {
/* storage can be blocked; the in-page theme still changes */
}
}
setTheme(next);
onThemeChange?.(next);
},
[onThemeChange, storageKey],
);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const surface = measureSurface(target, scopedName);
const current =
pendingThemeRef.current ?? resolveTheme(surface.element, theme);
const next: ThemeRevealTheme =
current === C.themes.light ? C.themes.dark : C.themes.light;
// Record the intent immediately so a second click during the transition
// toggles from where the first click is heading, not from the old DOM.
pendingThemeRef.current = next;
let click: Point | null = null;
if (fromClick) {
if (
event.detail > 0 &&
(event.clientX !== 0 || event.clientY !== 0)
) {
click = { x: event.clientX, y: event.clientY };
} else {
// Keyboard activation has no coordinates: reveal from the button itself.
const button = buttonRef.current;
if (button) {
const rect = button.getBoundingClientRect();
click = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
}
}
const origin = localOrigin(surface, click);
const radius = revealRadius(surface, origin);
const reduced =
surface.document.defaultView?.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches ?? false;
const durationMs = clamp(duration, C.duration.min, C.duration.max);
stopTransition();
const sequence = sequenceRef.current;
const commit = () => {
flushSync(() => commitTheme(surface.element, next));
};
const fadeFallback = () => {
flushSync(() => commitTheme(surface.element, next));
if (!fade || reduced) return;
const fadeNode = surface.root
? surface.document.documentElement
: surface.element;
try {
fadeNode.animate(
[{ opacity: C.fade.from }, { opacity: 1 }],
{
duration: C.fade.duration,
easing: C.easing,
fill: "none",
},
);
} catch {
/* WAAPI can be unavailable in very old browsers */
}
};
if (
reduced ||
typeof surface.document.startViewTransition !== "function"
) {
fadeFallback();
return;
}
if (scopedName && !surface.root) {
surface.element.style.viewTransitionName = scopedName;
scopedElementRef.current = surface.element;
}
let transition: ViewTransition;
try {
transition = surface.document.startViewTransition(commit);
} catch {
releaseScopedElement();
fadeFallback();
return;
}
transitionRef.current = transition;
const pseudoElement = surface.root
? "::view-transition-new(root)"
: `::view-transition-new(${scopedName})`;
transition.ready
.then(() => {
if (sequenceRef.current !== sequence) return;
if (!surface.element.isConnected) {
try {
transition.skipTransition();
} catch {
/* nothing left to skip */
}
return;
}
try {
surface.document.documentElement.animate(
{
clipPath: [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: durationMs,
easing: C.easing,
pseudoElement,
fill: "both",
},
);
} catch {
/* keep the settled new theme even if the clip cannot be animated */
}
})
.catch(() => {
/* ready rejects when the transition is skipped */
});
transition.finished
.catch(() => {
/* finished rejects if the update callback throws */
})
.finally(() => {
if (transitionRef.current === transition) {
transitionRef.current = null;
}
if (sequenceRef.current === sequence) releaseScopedElement();
});
};
return (
<>
<style>{transitionCss}</style>
<button
ref={buttonRef}
type="button"
onClick={handleClick}
aria-pressed={theme === C.themes.light}
data-theme-state={theme}
className={[
"inline-flex h-9 select-none items-center gap-2 rounded-full border px-3.5 text-[11.5px] font-medium tracking-[-0.01em] outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#c9f45a]",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={{
background: "rgba(127, 127, 127, 0.14)",
borderColor: "rgba(127, 127, 127, 0.32)",
color: "inherit",
touchAction: "manipulation",
}}
>
<span aria-hidden="true" className="inline-flex">
{theme === C.themes.dark ? (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4" />
</svg>
) : (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.5 14.6A8.6 8.6 0 0 1 9.4 3.5a8.6 8.6 0 1 0 11.1 11.1Z" />
</svg>
)}
</span>
<span>{label}</span>
</button>
</>
);
}
export type { ThemeRevealTheme } from "@/content/effects/theme-reveal/constants";
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
点击按钮,新主题从指针位置长成一个圆,用 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 在输出末尾逐条自检。
- 参数与实现一致:props 默认值必须等于本提示词 SPEC 段里的数值(时长、起点与降级淡入),圆形扩散在 200–700ms 之间可调、起点跟随 `fromClick`、`fade` 只在没有 View Transitions 时生效;数值不许四舍五入。
- 支持 View Transitions 的浏览器里,点击后 60–80ms 采样:新主题的圆半径严格大于 0 且小于最终半径,旧画面在圆外完全静止(没有淡出、位移或模糊)。
- 动画结束时 `::view-transition-new` 的 `clip-path` 已到最大半径;页面没有残留的行内 `clip-path`、`transform`、`view-transition-name` 等临时样式(组件自带的 `::view-transition-*` 规则 `<style>` 必须保留)。
- 切换前后 `<html>` 与演示舞台的 boundingBox 完全不变,不出现横向滚动条、布局跳动或滚动位置变化。
- prefers-reduced-motion: reduce 下不出现圆形扩散与淡入,主题在 ≤1ms 内直接到达最终状态。
- 键盘 Tab 能聚焦切换按钮并看到 2px 焦点环,Enter 与 Space 都能完成切换;焦点环不被揭示动画遮挡。
- 零控制台报错;快速连点不会叠加两个过渡(旧的被 `skipTransition()` 取消);浏览器不支持 View Transitions 时直接切换,不出现半透明残留。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。