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