From 6a6052211025960c31c1afb20c5570bbd2c4a60d Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Mon, 20 Jul 2026 22:36:14 +0900 Subject: [PATCH] fix(animated-theme-toggler): use percentage clip-path coordinates for view transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome 150 renders absolute px clip-path coordinates on ::view-transition-new(root) without applying the display scale factor on the first transition after page load, so on fractional scales (e.g. Windows 150%, devicePixelRatio 1.5) the first wipe starts at the wrong position — both coordinates land at exactly value/1.5 (#989). Express the whole clip-path geometry (circle center/radius and all polygon vertices across every variant) as percentages of the snapshot reference box instead of px. Percentages are resolved relative to the reference box, so they stay correct regardless of how Chrome scales the snapshot, on the first and every subsequent transition. Viewport denominators switch from visualViewport to innerWidth/innerHeight to match the reference box, which includes classic scrollbars. No visual change in unaffected browsers: the percentages resolve to the same geometry the px values produced. Fixes #989 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RDrm9vajALhxeHspRH68jj --- apps/www/public/llms-full.txt | 87 +++++++++++-------- apps/www/public/r/animated-theme-toggler.json | 2 +- .../magicui/animated-theme-toggler.tsx | 87 +++++++++++-------- 3 files changed, 107 insertions(+), 69 deletions(-) diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index de701bada..6d6b7de04 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3100,14 +3100,15 @@ interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"butt onThemeChange?: (theme: "light" | "dark") => void } -function polygonCollapsed(cx: number, cy: number, vertexCount: number): string { - const pairs = Array.from( - { length: vertexCount }, - () => `${cx}px ${cy}px` - ).join(", ") +function polygonCollapsed(point: string, vertexCount: number): string { + const pairs = Array.from({ length: vertexCount }, () => point).join(", ") return `polygon(${pairs})` } +// All coordinates are percentages of the snapshot reference box: Chrome 150 +// renders absolute px clip-path coordinates on ::view-transition-new(root) +// unscaled on fractional display scales (e.g. Windows 150%) for the first +// transition after load, so px values land at the wrong position (#989). function getThemeTransitionClipPaths( variant: TransitionVariant, cx: number, @@ -3116,64 +3117,74 @@ function getThemeTransitionClipPaths( viewportWidth: number, viewportHeight: number ): [string, string] { + const toX = (x: number) => `${(x / viewportWidth) * 100}%` + const toY = (y: number) => `${(y / viewportHeight) * 100}%` + const point = (x: number, y: number) => `${toX(x)} ${toY(y)}` + // circle() percentage radii resolve against hypot(w, h) / sqrt(2) of the reference box. + const toRadius = (r: number) => + `${(r / (Math.hypot(viewportWidth, viewportHeight) / Math.SQRT2)) * 100}%` + switch (variant) { case "circle": return [ - `circle(0px at ${cx}px ${cy}px)`, - `circle(${maxRadius}px at ${cx}px ${cy}px)`, + `circle(0% at ${point(cx, cy)})`, + `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`, ] case "square": { const halfW = Math.max(cx, viewportWidth - cx) const halfH = Math.max(cy, viewportHeight - cy) const halfSide = Math.max(halfW, halfH) * 1.05 const end = [ - `${cx - halfSide}px ${cy - halfSide}px`, - `${cx + halfSide}px ${cy - halfSide}px`, - `${cx + halfSide}px ${cy + halfSide}px`, - `${cx - halfSide}px ${cy + halfSide}px`, + point(cx - halfSide, cy - halfSide), + point(cx + halfSide, cy - halfSide), + point(cx + halfSide, cy + halfSide), + point(cx - halfSide, cy + halfSide), ].join(", ") - return [polygonCollapsed(cx, cy, 4), `polygon(${end})`] + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] } case "triangle": { const scale = maxRadius * 2.2 const dx = (Math.sqrt(3) / 2) * scale const verts = [ - `${cx}px ${cy - scale}px`, - `${cx + dx}px ${cy + 0.5 * scale}px`, - `${cx - dx}px ${cy + 0.5 * scale}px`, + point(cx, cy - scale), + point(cx + dx, cy + 0.5 * scale), + point(cx - dx, cy + 0.5 * scale), ].join(", ") - return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`] + return [polygonCollapsed(point(cx, cy), 3), `polygon(${verts})`] } case "diamond": { // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal. const R = maxRadius * Math.SQRT2 const end = [ - `${cx}px ${cy - R}px`, - `${cx + R}px ${cy}px`, - `${cx}px ${cy + R}px`, - `${cx - R}px ${cy}px`, + point(cx, cy - R), + point(cx + R, cy), + point(cx, cy + R), + point(cx - R, cy), ].join(", ") - return [polygonCollapsed(cx, cy, 4), `polygon(${end})`] + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] } case "hexagon": { const R = maxRadius * Math.SQRT2 const verts: string[] = [] for (let i = 0; i < 6; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 3 - verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`) + verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a))) } - return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`] + return [ + polygonCollapsed(point(cx, cy), 6), + `polygon(${verts.join(", ")})`, + ] } case "rectangle": { const halfW = Math.max(cx, viewportWidth - cx) const halfH = Math.max(cy, viewportHeight - cy) const end = [ - `${cx - halfW}px ${cy - halfH}px`, - `${cx + halfW}px ${cy - halfH}px`, - `${cx + halfW}px ${cy + halfH}px`, - `${cx - halfW}px ${cy + halfH}px`, + point(cx - halfW, cy - halfH), + point(cx + halfW, cy - halfH), + point(cx + halfW, cy + halfH), + point(cx - halfW, cy + halfH), ].join(", ") - return [polygonCollapsed(cx, cy, 4), `polygon(${end})`] + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] } case "star": { // Small overscan so the last frames never leave a 1px seam before the transition group ends. @@ -3184,11 +3195,17 @@ function getThemeTransitionClipPaths( for (let i = 0; i < 5; i++) { const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5 verts.push( - `${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px` + point( + cx + radius * Math.cos(outerA), + cy + radius * Math.sin(outerA) + ) ) const innerA = outerA + Math.PI / 5 verts.push( - `${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px` + point( + cx + radius * innerRatio * Math.cos(innerA), + cy + radius * innerRatio * Math.sin(innerA) + ) ) } return `polygon(${verts.join(", ")})` @@ -3198,8 +3215,8 @@ function getThemeTransitionClipPaths( } default: return [ - `circle(0px at ${cx}px ${cy}px)`, - `circle(${maxRadius}px at ${cx}px ${cy}px)`, + `circle(0% at ${point(cx, cy)})`, + `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`, ] } } @@ -3247,8 +3264,10 @@ export const AnimatedThemeToggler = ({ ) return - const viewportWidth = window.visualViewport?.width ?? window.innerWidth - const viewportHeight = window.visualViewport?.height ?? window.innerHeight + // innerWidth/innerHeight (not visualViewport): percentages must resolve + // against the snapshot reference box, which includes classic scrollbars. + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight let x: number let y: number diff --git a/apps/www/public/r/animated-theme-toggler.json b/apps/www/public/r/animated-theme-toggler.json index c07ea7f73..dc08fb3ff 100644 --- a/apps/www/public/r/animated-theme-toggler.json +++ b/apps/www/public/r/animated-theme-toggler.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/magicui/animated-theme-toggler.tsx", - "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { Moon, Sun } from \"lucide-react\"\nimport { flushSync } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type TransitionVariant =\n | \"circle\"\n | \"square\"\n | \"triangle\"\n | \"diamond\"\n | \"hexagon\"\n | \"rectangle\"\n | \"star\"\n\ninterface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<\"button\"> {\n duration?: number\n variant?: TransitionVariant\n /** When true, the transition expands from the viewport center instead of the button center. */\n fromCenter?: boolean\n /**\n * Controlled theme value. When provided, the parent owns persistence\n * (e.g. `next-themes`) and this component will not write to localStorage.\n */\n theme?: \"light\" | \"dark\"\n /** Called on toggle. Pair with `theme` for controlled usage. */\n onThemeChange?: (theme: \"light\" | \"dark\") => void\n}\n\nfunction polygonCollapsed(cx: number, cy: number, vertexCount: number): string {\n const pairs = Array.from(\n { length: vertexCount },\n () => `${cx}px ${cy}px`\n ).join(\", \")\n return `polygon(${pairs})`\n}\n\nfunction getThemeTransitionClipPaths(\n variant: TransitionVariant,\n cx: number,\n cy: number,\n maxRadius: number,\n viewportWidth: number,\n viewportHeight: number\n): [string, string] {\n switch (variant) {\n case \"circle\":\n return [\n `circle(0px at ${cx}px ${cy}px)`,\n `circle(${maxRadius}px at ${cx}px ${cy}px)`,\n ]\n case \"square\": {\n const halfW = Math.max(cx, viewportWidth - cx)\n const halfH = Math.max(cy, viewportHeight - cy)\n const halfSide = Math.max(halfW, halfH) * 1.05\n const end = [\n `${cx - halfSide}px ${cy - halfSide}px`,\n `${cx + halfSide}px ${cy - halfSide}px`,\n `${cx + halfSide}px ${cy + halfSide}px`,\n `${cx - halfSide}px ${cy + halfSide}px`,\n ].join(\", \")\n return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]\n }\n case \"triangle\": {\n const scale = maxRadius * 2.2\n const dx = (Math.sqrt(3) / 2) * scale\n const verts = [\n `${cx}px ${cy - scale}px`,\n `${cx + dx}px ${cy + 0.5 * scale}px`,\n `${cx - dx}px ${cy + 0.5 * scale}px`,\n ].join(\", \")\n return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]\n }\n case \"diamond\": {\n // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.\n const R = maxRadius * Math.SQRT2\n const end = [\n `${cx}px ${cy - R}px`,\n `${cx + R}px ${cy}px`,\n `${cx}px ${cy + R}px`,\n `${cx - R}px ${cy}px`,\n ].join(\", \")\n return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]\n }\n case \"hexagon\": {\n const R = maxRadius * Math.SQRT2\n const verts: string[] = []\n for (let i = 0; i < 6; i++) {\n const a = -Math.PI / 2 + (i * Math.PI) / 3\n verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)\n }\n return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(\", \")})`]\n }\n case \"rectangle\": {\n const halfW = Math.max(cx, viewportWidth - cx)\n const halfH = Math.max(cy, viewportHeight - cy)\n const end = [\n `${cx - halfW}px ${cy - halfH}px`,\n `${cx + halfW}px ${cy - halfH}px`,\n `${cx + halfW}px ${cy + halfH}px`,\n `${cx - halfW}px ${cy + halfH}px`,\n ].join(\", \")\n return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]\n }\n case \"star\": {\n // Small overscan so the last frames never leave a 1px seam before the transition group ends.\n const R = maxRadius * Math.SQRT2 * 1.03\n const innerRatio = 0.42\n const starPolygon = (radius: number) => {\n const verts: string[] = []\n for (let i = 0; i < 5; i++) {\n const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5\n verts.push(\n `${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`\n )\n const innerA = outerA + Math.PI / 5\n verts.push(\n `${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`\n )\n }\n return `polygon(${verts.join(\", \")})`\n }\n const startR = Math.max(2, R * 0.025)\n return [starPolygon(startR), starPolygon(R)]\n }\n default:\n return [\n `circle(0px at ${cx}px ${cy}px)`,\n `circle(${maxRadius}px at ${cx}px ${cy}px)`,\n ]\n }\n}\n\nexport const AnimatedThemeToggler = ({\n className,\n duration = 400,\n variant,\n fromCenter = false,\n theme,\n onThemeChange,\n ...props\n}: AnimatedThemeTogglerProps) => {\n const shape = variant ?? \"circle\"\n const isControlled = theme !== undefined\n const [internalIsDark, setInternalIsDark] = useState(false)\n const isDark = isControlled ? theme === \"dark\" : internalIsDark\n const buttonRef = useRef(null)\n const isTransitioningRef = useRef(false)\n\n useEffect(() => {\n if (isControlled) return\n\n const updateTheme = () => {\n setInternalIsDark(document.documentElement.classList.contains(\"dark\"))\n }\n\n updateTheme()\n\n const observer = new MutationObserver(updateTheme)\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n })\n\n return () => observer.disconnect()\n }, [isControlled])\n\n const toggleTheme = useCallback(() => {\n const button = buttonRef.current\n if (\n !button ||\n isTransitioningRef.current ||\n document.documentElement.dataset.magicuiThemeVt === \"active\"\n )\n return\n\n const viewportWidth = window.visualViewport?.width ?? window.innerWidth\n const viewportHeight = window.visualViewport?.height ?? window.innerHeight\n\n let x: number\n let y: number\n if (fromCenter) {\n x = viewportWidth / 2\n y = viewportHeight / 2\n } else {\n const { top, left, width, height } = button.getBoundingClientRect()\n x = left + width / 2\n y = top + height / 2\n }\n\n const maxRadius = Math.hypot(\n Math.max(x, viewportWidth - x),\n Math.max(y, viewportHeight - y)\n )\n\n const applyTheme = () => {\n const newTheme = !isDark\n // Always toggle the class synchronously so the View Transitions API\n // snapshots the new theme inside the startViewTransition callback.\n document.documentElement.classList.toggle(\"dark\")\n if (isControlled) {\n onThemeChange?.(newTheme ? \"dark\" : \"light\")\n } else {\n setInternalIsDark(newTheme)\n localStorage.setItem(\"theme\", newTheme ? \"dark\" : \"light\")\n }\n }\n\n if (typeof document.startViewTransition !== \"function\") {\n applyTheme()\n return\n }\n\n const clipPath = getThemeTransitionClipPaths(\n shape,\n x,\n y,\n maxRadius,\n viewportWidth,\n viewportHeight\n )\n\n const root = document.documentElement\n root.dataset.magicuiThemeVt = \"active\"\n root.style.setProperty(\n \"--magicui-theme-toggle-vt-duration\",\n `${duration}ms`\n )\n // Pin the collapsed clip-path via CSS so Firefox does not paint the new\n // theme unclipped between snapshot and the ready.then() JS animation.\n root.style.setProperty(\"--magicui-theme-vt-clip-from\", clipPath[0])\n const cleanup = () => {\n isTransitioningRef.current = false\n delete root.dataset.magicuiThemeVt\n root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\")\n root.style.removeProperty(\"--magicui-theme-vt-clip-from\")\n }\n\n isTransitioningRef.current = true\n const transition = document.startViewTransition(() => {\n flushSync(applyTheme)\n })\n if (typeof transition?.finished?.finally === \"function\") {\n transition.finished.finally(cleanup).catch(() => {})\n } else {\n cleanup()\n }\n\n const ready = transition?.ready\n if (ready && typeof ready.then === \"function\") {\n ready\n .then(() => {\n document.documentElement.animate(\n {\n clipPath,\n },\n {\n duration,\n // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.\n easing: shape === \"star\" ? \"linear\" : \"ease-in-out\",\n fill: \"forwards\",\n pseudoElement: \"::view-transition-new(root)\",\n }\n )\n })\n .catch(() => {})\n }\n }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange])\n\n return (\n \n {isDark ? : }\n Toggle theme\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { Moon, Sun } from \"lucide-react\"\nimport { flushSync } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type TransitionVariant =\n | \"circle\"\n | \"square\"\n | \"triangle\"\n | \"diamond\"\n | \"hexagon\"\n | \"rectangle\"\n | \"star\"\n\ninterface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<\"button\"> {\n duration?: number\n variant?: TransitionVariant\n /** When true, the transition expands from the viewport center instead of the button center. */\n fromCenter?: boolean\n /**\n * Controlled theme value. When provided, the parent owns persistence\n * (e.g. `next-themes`) and this component will not write to localStorage.\n */\n theme?: \"light\" | \"dark\"\n /** Called on toggle. Pair with `theme` for controlled usage. */\n onThemeChange?: (theme: \"light\" | \"dark\") => void\n}\n\nfunction polygonCollapsed(point: string, vertexCount: number): string {\n const pairs = Array.from({ length: vertexCount }, () => point).join(\", \")\n return `polygon(${pairs})`\n}\n\n// All coordinates are percentages of the snapshot reference box: Chrome 150\n// renders absolute px clip-path coordinates on ::view-transition-new(root)\n// unscaled on fractional display scales (e.g. Windows 150%) for the first\n// transition after load, so px values land at the wrong position (#989).\nfunction getThemeTransitionClipPaths(\n variant: TransitionVariant,\n cx: number,\n cy: number,\n maxRadius: number,\n viewportWidth: number,\n viewportHeight: number\n): [string, string] {\n const toX = (x: number) => `${(x / viewportWidth) * 100}%`\n const toY = (y: number) => `${(y / viewportHeight) * 100}%`\n const point = (x: number, y: number) => `${toX(x)} ${toY(y)}`\n // circle() percentage radii resolve against hypot(w, h) / sqrt(2) of the reference box.\n const toRadius = (r: number) =>\n `${(r / (Math.hypot(viewportWidth, viewportHeight) / Math.SQRT2)) * 100}%`\n\n switch (variant) {\n case \"circle\":\n return [\n `circle(0% at ${point(cx, cy)})`,\n `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`,\n ]\n case \"square\": {\n const halfW = Math.max(cx, viewportWidth - cx)\n const halfH = Math.max(cy, viewportHeight - cy)\n const halfSide = Math.max(halfW, halfH) * 1.05\n const end = [\n point(cx - halfSide, cy - halfSide),\n point(cx + halfSide, cy - halfSide),\n point(cx + halfSide, cy + halfSide),\n point(cx - halfSide, cy + halfSide),\n ].join(\", \")\n return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`]\n }\n case \"triangle\": {\n const scale = maxRadius * 2.2\n const dx = (Math.sqrt(3) / 2) * scale\n const verts = [\n point(cx, cy - scale),\n point(cx + dx, cy + 0.5 * scale),\n point(cx - dx, cy + 0.5 * scale),\n ].join(\", \")\n return [polygonCollapsed(point(cx, cy), 3), `polygon(${verts})`]\n }\n case \"diamond\": {\n // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.\n const R = maxRadius * Math.SQRT2\n const end = [\n point(cx, cy - R),\n point(cx + R, cy),\n point(cx, cy + R),\n point(cx - R, cy),\n ].join(\", \")\n return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`]\n }\n case \"hexagon\": {\n const R = maxRadius * Math.SQRT2\n const verts: string[] = []\n for (let i = 0; i < 6; i++) {\n const a = -Math.PI / 2 + (i * Math.PI) / 3\n verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a)))\n }\n return [\n polygonCollapsed(point(cx, cy), 6),\n `polygon(${verts.join(\", \")})`,\n ]\n }\n case \"rectangle\": {\n const halfW = Math.max(cx, viewportWidth - cx)\n const halfH = Math.max(cy, viewportHeight - cy)\n const end = [\n point(cx - halfW, cy - halfH),\n point(cx + halfW, cy - halfH),\n point(cx + halfW, cy + halfH),\n point(cx - halfW, cy + halfH),\n ].join(\", \")\n return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`]\n }\n case \"star\": {\n // Small overscan so the last frames never leave a 1px seam before the transition group ends.\n const R = maxRadius * Math.SQRT2 * 1.03\n const innerRatio = 0.42\n const starPolygon = (radius: number) => {\n const verts: string[] = []\n for (let i = 0; i < 5; i++) {\n const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5\n verts.push(\n point(\n cx + radius * Math.cos(outerA),\n cy + radius * Math.sin(outerA)\n )\n )\n const innerA = outerA + Math.PI / 5\n verts.push(\n point(\n cx + radius * innerRatio * Math.cos(innerA),\n cy + radius * innerRatio * Math.sin(innerA)\n )\n )\n }\n return `polygon(${verts.join(\", \")})`\n }\n const startR = Math.max(2, R * 0.025)\n return [starPolygon(startR), starPolygon(R)]\n }\n default:\n return [\n `circle(0% at ${point(cx, cy)})`,\n `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`,\n ]\n }\n}\n\nexport const AnimatedThemeToggler = ({\n className,\n duration = 400,\n variant,\n fromCenter = false,\n theme,\n onThemeChange,\n ...props\n}: AnimatedThemeTogglerProps) => {\n const shape = variant ?? \"circle\"\n const isControlled = theme !== undefined\n const [internalIsDark, setInternalIsDark] = useState(false)\n const isDark = isControlled ? theme === \"dark\" : internalIsDark\n const buttonRef = useRef(null)\n const isTransitioningRef = useRef(false)\n\n useEffect(() => {\n if (isControlled) return\n\n const updateTheme = () => {\n setInternalIsDark(document.documentElement.classList.contains(\"dark\"))\n }\n\n updateTheme()\n\n const observer = new MutationObserver(updateTheme)\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n })\n\n return () => observer.disconnect()\n }, [isControlled])\n\n const toggleTheme = useCallback(() => {\n const button = buttonRef.current\n if (\n !button ||\n isTransitioningRef.current ||\n document.documentElement.dataset.magicuiThemeVt === \"active\"\n )\n return\n\n // innerWidth/innerHeight (not visualViewport): percentages must resolve\n // against the snapshot reference box, which includes classic scrollbars.\n const viewportWidth = window.innerWidth\n const viewportHeight = window.innerHeight\n\n let x: number\n let y: number\n if (fromCenter) {\n x = viewportWidth / 2\n y = viewportHeight / 2\n } else {\n const { top, left, width, height } = button.getBoundingClientRect()\n x = left + width / 2\n y = top + height / 2\n }\n\n const maxRadius = Math.hypot(\n Math.max(x, viewportWidth - x),\n Math.max(y, viewportHeight - y)\n )\n\n const applyTheme = () => {\n const newTheme = !isDark\n // Always toggle the class synchronously so the View Transitions API\n // snapshots the new theme inside the startViewTransition callback.\n document.documentElement.classList.toggle(\"dark\")\n if (isControlled) {\n onThemeChange?.(newTheme ? \"dark\" : \"light\")\n } else {\n setInternalIsDark(newTheme)\n localStorage.setItem(\"theme\", newTheme ? \"dark\" : \"light\")\n }\n }\n\n if (typeof document.startViewTransition !== \"function\") {\n applyTheme()\n return\n }\n\n const clipPath = getThemeTransitionClipPaths(\n shape,\n x,\n y,\n maxRadius,\n viewportWidth,\n viewportHeight\n )\n\n const root = document.documentElement\n root.dataset.magicuiThemeVt = \"active\"\n root.style.setProperty(\n \"--magicui-theme-toggle-vt-duration\",\n `${duration}ms`\n )\n // Pin the collapsed clip-path via CSS so Firefox does not paint the new\n // theme unclipped between snapshot and the ready.then() JS animation.\n root.style.setProperty(\"--magicui-theme-vt-clip-from\", clipPath[0])\n const cleanup = () => {\n isTransitioningRef.current = false\n delete root.dataset.magicuiThemeVt\n root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\")\n root.style.removeProperty(\"--magicui-theme-vt-clip-from\")\n }\n\n isTransitioningRef.current = true\n const transition = document.startViewTransition(() => {\n flushSync(applyTheme)\n })\n if (typeof transition?.finished?.finally === \"function\") {\n transition.finished.finally(cleanup).catch(() => {})\n } else {\n cleanup()\n }\n\n const ready = transition?.ready\n if (ready && typeof ready.then === \"function\") {\n ready\n .then(() => {\n document.documentElement.animate(\n {\n clipPath,\n },\n {\n duration,\n // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.\n easing: shape === \"star\" ? \"linear\" : \"ease-in-out\",\n fill: \"forwards\",\n pseudoElement: \"::view-transition-new(root)\",\n }\n )\n })\n .catch(() => {})\n }\n }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange])\n\n return (\n \n {isDark ? : }\n Toggle theme\n \n )\n}\n", "type": "registry:ui" } ], diff --git a/apps/www/registry/magicui/animated-theme-toggler.tsx b/apps/www/registry/magicui/animated-theme-toggler.tsx index 6bffd9135..f1c57dbc3 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -29,14 +29,15 @@ interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"butt onThemeChange?: (theme: "light" | "dark") => void } -function polygonCollapsed(cx: number, cy: number, vertexCount: number): string { - const pairs = Array.from( - { length: vertexCount }, - () => `${cx}px ${cy}px` - ).join(", ") +function polygonCollapsed(point: string, vertexCount: number): string { + const pairs = Array.from({ length: vertexCount }, () => point).join(", ") return `polygon(${pairs})` } +// All coordinates are percentages of the snapshot reference box: Chrome 150 +// renders absolute px clip-path coordinates on ::view-transition-new(root) +// unscaled on fractional display scales (e.g. Windows 150%) for the first +// transition after load, so px values land at the wrong position (#989). function getThemeTransitionClipPaths( variant: TransitionVariant, cx: number, @@ -45,64 +46,74 @@ function getThemeTransitionClipPaths( viewportWidth: number, viewportHeight: number ): [string, string] { + const toX = (x: number) => `${(x / viewportWidth) * 100}%` + const toY = (y: number) => `${(y / viewportHeight) * 100}%` + const point = (x: number, y: number) => `${toX(x)} ${toY(y)}` + // circle() percentage radii resolve against hypot(w, h) / sqrt(2) of the reference box. + const toRadius = (r: number) => + `${(r / (Math.hypot(viewportWidth, viewportHeight) / Math.SQRT2)) * 100}%` + switch (variant) { case "circle": return [ - `circle(0px at ${cx}px ${cy}px)`, - `circle(${maxRadius}px at ${cx}px ${cy}px)`, + `circle(0% at ${point(cx, cy)})`, + `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`, ] case "square": { const halfW = Math.max(cx, viewportWidth - cx) const halfH = Math.max(cy, viewportHeight - cy) const halfSide = Math.max(halfW, halfH) * 1.05 const end = [ - `${cx - halfSide}px ${cy - halfSide}px`, - `${cx + halfSide}px ${cy - halfSide}px`, - `${cx + halfSide}px ${cy + halfSide}px`, - `${cx - halfSide}px ${cy + halfSide}px`, + point(cx - halfSide, cy - halfSide), + point(cx + halfSide, cy - halfSide), + point(cx + halfSide, cy + halfSide), + point(cx - halfSide, cy + halfSide), ].join(", ") - return [polygonCollapsed(cx, cy, 4), `polygon(${end})`] + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] } case "triangle": { const scale = maxRadius * 2.2 const dx = (Math.sqrt(3) / 2) * scale const verts = [ - `${cx}px ${cy - scale}px`, - `${cx + dx}px ${cy + 0.5 * scale}px`, - `${cx - dx}px ${cy + 0.5 * scale}px`, + point(cx, cy - scale), + point(cx + dx, cy + 0.5 * scale), + point(cx - dx, cy + 0.5 * scale), ].join(", ") - return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`] + return [polygonCollapsed(point(cx, cy), 3), `polygon(${verts})`] } case "diamond": { // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal. const R = maxRadius * Math.SQRT2 const end = [ - `${cx}px ${cy - R}px`, - `${cx + R}px ${cy}px`, - `${cx}px ${cy + R}px`, - `${cx - R}px ${cy}px`, + point(cx, cy - R), + point(cx + R, cy), + point(cx, cy + R), + point(cx - R, cy), ].join(", ") - return [polygonCollapsed(cx, cy, 4), `polygon(${end})`] + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] } case "hexagon": { const R = maxRadius * Math.SQRT2 const verts: string[] = [] for (let i = 0; i < 6; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 3 - verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`) + verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a))) } - return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`] + return [ + polygonCollapsed(point(cx, cy), 6), + `polygon(${verts.join(", ")})`, + ] } case "rectangle": { const halfW = Math.max(cx, viewportWidth - cx) const halfH = Math.max(cy, viewportHeight - cy) const end = [ - `${cx - halfW}px ${cy - halfH}px`, - `${cx + halfW}px ${cy - halfH}px`, - `${cx + halfW}px ${cy + halfH}px`, - `${cx - halfW}px ${cy + halfH}px`, + point(cx - halfW, cy - halfH), + point(cx + halfW, cy - halfH), + point(cx + halfW, cy + halfH), + point(cx - halfW, cy + halfH), ].join(", ") - return [polygonCollapsed(cx, cy, 4), `polygon(${end})`] + return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`] } case "star": { // Small overscan so the last frames never leave a 1px seam before the transition group ends. @@ -113,11 +124,17 @@ function getThemeTransitionClipPaths( for (let i = 0; i < 5; i++) { const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5 verts.push( - `${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px` + point( + cx + radius * Math.cos(outerA), + cy + radius * Math.sin(outerA) + ) ) const innerA = outerA + Math.PI / 5 verts.push( - `${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px` + point( + cx + radius * innerRatio * Math.cos(innerA), + cy + radius * innerRatio * Math.sin(innerA) + ) ) } return `polygon(${verts.join(", ")})` @@ -127,8 +144,8 @@ function getThemeTransitionClipPaths( } default: return [ - `circle(0px at ${cx}px ${cy}px)`, - `circle(${maxRadius}px at ${cx}px ${cy}px)`, + `circle(0% at ${point(cx, cy)})`, + `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`, ] } } @@ -176,8 +193,10 @@ export const AnimatedThemeToggler = ({ ) return - const viewportWidth = window.visualViewport?.width ?? window.innerWidth - const viewportHeight = window.visualViewport?.height ?? window.innerHeight + // innerWidth/innerHeight (not visualViewport): percentages must resolve + // against the snapshot reference box, which includes classic scrollbars. + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight let x: number let y: number