From 70174a3f64702a516daf3e2e595f3e055c602bc9 Mon Sep 17 00:00:00 2001 From: Krishnendu Das Date: Sat, 18 Jul 2026 02:35:51 +0530 Subject: [PATCH 1/5] fix(animated-theme-toggler): prevent compositor crash on rapid click --- apps/www/public/llms-full.txt | 121 +++++++++++++----- apps/www/public/r/animated-theme-toggler.json | 2 +- .../magicui/animated-theme-toggler.tsx | 121 +++++++++++++----- 3 files changed, 181 insertions(+), 63 deletions(-) diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index c84bf080e..f71d78e1c 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3218,6 +3218,9 @@ export const AnimatedThemeToggler = ({ const [internalIsDark, setInternalIsDark] = useState(false) const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) + const [isTransitioning, setIsTransitioning] = useState(false) + const isTransitioningRef = useRef(false) + const lastToggleTimeRef = useRef(0) useEffect(() => { if (isControlled) return @@ -3237,9 +3240,24 @@ export const AnimatedThemeToggler = ({ return () => observer.disconnect() }, [isControlled]) + useEffect(() => { + return () => { + const root = document.documentElement + delete root.dataset.magicuiThemeVt + root.style.removeProperty("--magicui-theme-toggle-vt-duration") + root.style.removeProperty("--magicui-theme-vt-clip-from") + root.removeAttribute("inert") + } + }, []) + const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button) return + if (!button || isTransitioningRef.current || isTransitioning) return + + const now = performance.now() + // Enforce a strict minimum delay of 500ms between transitions to prevent compositor crashes + if (now - lastToggleTimeRef.current < 500) return + lastToggleTimeRef.current = now const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportHeight = window.visualViewport?.height ?? window.innerHeight @@ -3296,51 +3314,92 @@ export const AnimatedThemeToggler = ({ // Pin the collapsed clip-path via CSS so Firefox does not paint the new // theme unclipped between snapshot and the ready.then() JS animation. root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) + const cleanup = () => { delete root.dataset.magicuiThemeVt root.style.removeProperty("--magicui-theme-toggle-vt-duration") root.style.removeProperty("--magicui-theme-vt-clip-from") + root.removeAttribute("inert") + isTransitioningRef.current = false + setIsTransitioning(false) } + isTransitioningRef.current = true + root.setAttribute("inert", "true") + flushSync(() => { + setIsTransitioning(true) + }) + const transition = document.startViewTransition(() => { flushSync(applyTheme) }) - if (typeof transition?.finished?.finally === "function") { - transition.finished.finally(cleanup) - } else { - cleanup() - } const ready = transition?.ready if (ready && typeof ready.then === "function") { - ready.then(() => { - document.documentElement.animate( - { - clipPath, - }, - { - duration, - // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above. - easing: shape === "star" ? "linear" : "ease-in-out", - fill: "forwards", - pseudoElement: "::view-transition-new(root)", - } - ) - }) + ready + .then(() => { + const anim = document.documentElement.animate( + { + clipPath, + }, + { + duration, + // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above. + easing: shape === "star" ? "linear" : "ease-in-out", + fill: "forwards", + pseudoElement: "::view-transition-new(root)", + } + ) + anim.finished.then( + () => setTimeout(cleanup, 100), + () => setTimeout(cleanup, 100) + ) + }) + .catch(() => { + cleanup() + }) + } else { + cleanup() } - }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) + }, [ + shape, + fromCenter, + duration, + isDark, + isControlled, + onThemeChange, + isTransitioning, + ]) return ( - + <> + + + ) } diff --git a/apps/www/public/r/animated-theme-toggler.json b/apps/www/public/r/animated-theme-toggler.json index 0bd9dd21c..3097a160b 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\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 (!button) 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 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 const transition = document.startViewTransition(() => {\n flushSync(applyTheme)\n })\n if (typeof transition?.finished?.finally === \"function\") {\n transition.finished.finally(cleanup)\n } else {\n cleanup()\n }\n\n const ready = transition?.ready\n if (ready && typeof ready.then === \"function\") {\n ready.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 }\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(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 [isTransitioning, setIsTransitioning] = useState(false)\n const isTransitioningRef = useRef(false)\n const lastToggleTimeRef = useRef(0)\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 useEffect(() => {\n return () => {\n const root = document.documentElement\n delete root.dataset.magicuiThemeVt\n root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\")\n root.style.removeProperty(\"--magicui-theme-vt-clip-from\")\n root.removeAttribute(\"inert\")\n }\n }, [])\n\n const toggleTheme = useCallback(() => {\n const button = buttonRef.current\n if (!button || isTransitioningRef.current || isTransitioning) return\n\n const now = performance.now()\n // Enforce a strict minimum delay of 500ms between transitions to prevent compositor crashes\n if (now - lastToggleTimeRef.current < 500) return\n lastToggleTimeRef.current = now\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\n const cleanup = () => {\n delete root.dataset.magicuiThemeVt\n root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\")\n root.style.removeProperty(\"--magicui-theme-vt-clip-from\")\n root.removeAttribute(\"inert\")\n isTransitioningRef.current = false\n setIsTransitioning(false)\n }\n\n isTransitioningRef.current = true\n root.setAttribute(\"inert\", \"true\")\n flushSync(() => {\n setIsTransitioning(true)\n })\n\n const transition = document.startViewTransition(() => {\n flushSync(applyTheme)\n })\n\n const ready = transition?.ready\n if (ready && typeof ready.then === \"function\") {\n ready\n .then(() => {\n const anim = 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 anim.finished.then(\n () => setTimeout(cleanup, 100),\n () => setTimeout(cleanup, 100)\n )\n })\n .catch(() => {\n cleanup()\n })\n } else {\n cleanup()\n }\n }, [\n shape,\n fromCenter,\n duration,\n isDark,\n isControlled,\n onThemeChange,\n isTransitioning,\n ])\n\n return (\n <>\n \n \n {isDark ? : }\n Toggle theme\n \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 7b2649b39..36ed7f386 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -147,6 +147,9 @@ export const AnimatedThemeToggler = ({ const [internalIsDark, setInternalIsDark] = useState(false) const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) + const [isTransitioning, setIsTransitioning] = useState(false) + const isTransitioningRef = useRef(false) + const lastToggleTimeRef = useRef(0) useEffect(() => { if (isControlled) return @@ -166,9 +169,24 @@ export const AnimatedThemeToggler = ({ return () => observer.disconnect() }, [isControlled]) + useEffect(() => { + return () => { + const root = document.documentElement + delete root.dataset.magicuiThemeVt + root.style.removeProperty("--magicui-theme-toggle-vt-duration") + root.style.removeProperty("--magicui-theme-vt-clip-from") + root.removeAttribute("inert") + } + }, []) + const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button) return + if (!button || isTransitioningRef.current || isTransitioning) return + + const now = performance.now() + // Enforce a strict minimum delay of 500ms between transitions to prevent compositor crashes + if (now - lastToggleTimeRef.current < 500) return + lastToggleTimeRef.current = now const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportHeight = window.visualViewport?.height ?? window.innerHeight @@ -225,50 +243,91 @@ export const AnimatedThemeToggler = ({ // Pin the collapsed clip-path via CSS so Firefox does not paint the new // theme unclipped between snapshot and the ready.then() JS animation. root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) + const cleanup = () => { delete root.dataset.magicuiThemeVt root.style.removeProperty("--magicui-theme-toggle-vt-duration") root.style.removeProperty("--magicui-theme-vt-clip-from") + root.removeAttribute("inert") + isTransitioningRef.current = false + setIsTransitioning(false) } + isTransitioningRef.current = true + root.setAttribute("inert", "true") + flushSync(() => { + setIsTransitioning(true) + }) + const transition = document.startViewTransition(() => { flushSync(applyTheme) }) - if (typeof transition?.finished?.finally === "function") { - transition.finished.finally(cleanup) - } else { - cleanup() - } const ready = transition?.ready if (ready && typeof ready.then === "function") { - ready.then(() => { - document.documentElement.animate( - { - clipPath, - }, - { - duration, - // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above. - easing: shape === "star" ? "linear" : "ease-in-out", - fill: "forwards", - pseudoElement: "::view-transition-new(root)", - } - ) - }) + ready + .then(() => { + const anim = document.documentElement.animate( + { + clipPath, + }, + { + duration, + // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above. + easing: shape === "star" ? "linear" : "ease-in-out", + fill: "forwards", + pseudoElement: "::view-transition-new(root)", + } + ) + anim.finished.then( + () => setTimeout(cleanup, 100), + () => setTimeout(cleanup, 100) + ) + }) + .catch(() => { + cleanup() + }) + } else { + cleanup() } - }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) + }, [ + shape, + fromCenter, + duration, + isDark, + isControlled, + onThemeChange, + isTransitioning, + ]) return ( - + <> + + + ) } From 0133409abad48ff4b741b95c284071403f5b7046 Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Sun, 19 Jul 2026 15:44:58 +0900 Subject: [PATCH 2/5] refactor(animated-theme-toggler): simplify crash fix to a single transition lock --- .claude/worktrees/fix+toggler-crash | 1 + apps/www/public/llms-full.txt | 94 ++++--------------- apps/www/public/r/animated-theme-toggler.json | 2 +- .../magicui/animated-theme-toggler.tsx | 94 ++++--------------- 4 files changed, 42 insertions(+), 149 deletions(-) create mode 160000 .claude/worktrees/fix+toggler-crash diff --git a/.claude/worktrees/fix+toggler-crash b/.claude/worktrees/fix+toggler-crash new file mode 160000 index 000000000..37f7285f1 --- /dev/null +++ b/.claude/worktrees/fix+toggler-crash @@ -0,0 +1 @@ +Subproject commit 37f7285f1a899ca0dac15ce167198263c75bef49 diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index f71d78e1c..2fcf8c537 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3218,9 +3218,7 @@ export const AnimatedThemeToggler = ({ const [internalIsDark, setInternalIsDark] = useState(false) const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) - const [isTransitioning, setIsTransitioning] = useState(false) const isTransitioningRef = useRef(false) - const lastToggleTimeRef = useRef(0) useEffect(() => { if (isControlled) return @@ -3240,24 +3238,9 @@ export const AnimatedThemeToggler = ({ return () => observer.disconnect() }, [isControlled]) - useEffect(() => { - return () => { - const root = document.documentElement - delete root.dataset.magicuiThemeVt - root.style.removeProperty("--magicui-theme-toggle-vt-duration") - root.style.removeProperty("--magicui-theme-vt-clip-from") - root.removeAttribute("inert") - } - }, []) - const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button || isTransitioningRef.current || isTransitioning) return - - const now = performance.now() - // Enforce a strict minimum delay of 500ms between transitions to prevent compositor crashes - if (now - lastToggleTimeRef.current < 500) return - lastToggleTimeRef.current = now + if (!button || isTransitioningRef.current) return const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportHeight = window.visualViewport?.height ?? window.innerHeight @@ -3314,31 +3297,28 @@ export const AnimatedThemeToggler = ({ // Pin the collapsed clip-path via CSS so Firefox does not paint the new // theme unclipped between snapshot and the ready.then() JS animation. root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) - const cleanup = () => { + isTransitioningRef.current = false delete root.dataset.magicuiThemeVt root.style.removeProperty("--magicui-theme-toggle-vt-duration") root.style.removeProperty("--magicui-theme-vt-clip-from") - root.removeAttribute("inert") - isTransitioningRef.current = false - setIsTransitioning(false) } isTransitioningRef.current = true - root.setAttribute("inert", "true") - flushSync(() => { - setIsTransitioning(true) - }) - const transition = document.startViewTransition(() => { flushSync(applyTheme) }) + if (typeof transition?.finished?.finally === "function") { + transition.finished.finally(cleanup) + } else { + cleanup() + } const ready = transition?.ready if (ready && typeof ready.then === "function") { ready .then(() => { - const anim = document.documentElement.animate( + document.documentElement.animate( { clipPath, }, @@ -3350,56 +3330,22 @@ export const AnimatedThemeToggler = ({ pseudoElement: "::view-transition-new(root)", } ) - anim.finished.then( - () => setTimeout(cleanup, 100), - () => setTimeout(cleanup, 100) - ) - }) - .catch(() => { - cleanup() }) - } else { - cleanup() + .catch(() => {}) } - }, [ - shape, - fromCenter, - duration, - isDark, - isControlled, - onThemeChange, - isTransitioning, - ]) + }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) return ( - <> - - - + ) } diff --git a/apps/www/public/r/animated-theme-toggler.json b/apps/www/public/r/animated-theme-toggler.json index 3097a160b..0458db55c 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 [isTransitioning, setIsTransitioning] = useState(false)\n const isTransitioningRef = useRef(false)\n const lastToggleTimeRef = useRef(0)\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 useEffect(() => {\n return () => {\n const root = document.documentElement\n delete root.dataset.magicuiThemeVt\n root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\")\n root.style.removeProperty(\"--magicui-theme-vt-clip-from\")\n root.removeAttribute(\"inert\")\n }\n }, [])\n\n const toggleTheme = useCallback(() => {\n const button = buttonRef.current\n if (!button || isTransitioningRef.current || isTransitioning) return\n\n const now = performance.now()\n // Enforce a strict minimum delay of 500ms between transitions to prevent compositor crashes\n if (now - lastToggleTimeRef.current < 500) return\n lastToggleTimeRef.current = now\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\n const cleanup = () => {\n delete root.dataset.magicuiThemeVt\n root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\")\n root.style.removeProperty(\"--magicui-theme-vt-clip-from\")\n root.removeAttribute(\"inert\")\n isTransitioningRef.current = false\n setIsTransitioning(false)\n }\n\n isTransitioningRef.current = true\n root.setAttribute(\"inert\", \"true\")\n flushSync(() => {\n setIsTransitioning(true)\n })\n\n const transition = document.startViewTransition(() => {\n flushSync(applyTheme)\n })\n\n const ready = transition?.ready\n if (ready && typeof ready.then === \"function\") {\n ready\n .then(() => {\n const anim = 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 anim.finished.then(\n () => setTimeout(cleanup, 100),\n () => setTimeout(cleanup, 100)\n )\n })\n .catch(() => {\n cleanup()\n })\n } else {\n cleanup()\n }\n }, [\n shape,\n fromCenter,\n duration,\n isDark,\n isControlled,\n onThemeChange,\n isTransitioning,\n ])\n\n return (\n <>\n \n \n {isDark ? : }\n Toggle theme\n \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(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 (!button || isTransitioningRef.current) 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)\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 36ed7f386..2a3c657e3 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -147,9 +147,7 @@ export const AnimatedThemeToggler = ({ const [internalIsDark, setInternalIsDark] = useState(false) const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) - const [isTransitioning, setIsTransitioning] = useState(false) const isTransitioningRef = useRef(false) - const lastToggleTimeRef = useRef(0) useEffect(() => { if (isControlled) return @@ -169,24 +167,9 @@ export const AnimatedThemeToggler = ({ return () => observer.disconnect() }, [isControlled]) - useEffect(() => { - return () => { - const root = document.documentElement - delete root.dataset.magicuiThemeVt - root.style.removeProperty("--magicui-theme-toggle-vt-duration") - root.style.removeProperty("--magicui-theme-vt-clip-from") - root.removeAttribute("inert") - } - }, []) - const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button || isTransitioningRef.current || isTransitioning) return - - const now = performance.now() - // Enforce a strict minimum delay of 500ms between transitions to prevent compositor crashes - if (now - lastToggleTimeRef.current < 500) return - lastToggleTimeRef.current = now + if (!button || isTransitioningRef.current) return const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportHeight = window.visualViewport?.height ?? window.innerHeight @@ -243,31 +226,28 @@ export const AnimatedThemeToggler = ({ // Pin the collapsed clip-path via CSS so Firefox does not paint the new // theme unclipped between snapshot and the ready.then() JS animation. root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]) - const cleanup = () => { + isTransitioningRef.current = false delete root.dataset.magicuiThemeVt root.style.removeProperty("--magicui-theme-toggle-vt-duration") root.style.removeProperty("--magicui-theme-vt-clip-from") - root.removeAttribute("inert") - isTransitioningRef.current = false - setIsTransitioning(false) } isTransitioningRef.current = true - root.setAttribute("inert", "true") - flushSync(() => { - setIsTransitioning(true) - }) - const transition = document.startViewTransition(() => { flushSync(applyTheme) }) + if (typeof transition?.finished?.finally === "function") { + transition.finished.finally(cleanup) + } else { + cleanup() + } const ready = transition?.ready if (ready && typeof ready.then === "function") { ready .then(() => { - const anim = document.documentElement.animate( + document.documentElement.animate( { clipPath, }, @@ -279,55 +259,21 @@ export const AnimatedThemeToggler = ({ pseudoElement: "::view-transition-new(root)", } ) - anim.finished.then( - () => setTimeout(cleanup, 100), - () => setTimeout(cleanup, 100) - ) }) - .catch(() => { - cleanup() - }) - } else { - cleanup() + .catch(() => {}) } - }, [ - shape, - fromCenter, - duration, - isDark, - isControlled, - onThemeChange, - isTransitioning, - ]) + }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) return ( - <> - - - + ) } From 873199dc4af7b5aee381c0c7c456082bf0d3de1f Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Sun, 19 Jul 2026 15:46:48 +0900 Subject: [PATCH 3/5] fix(animated-theme-toggler): block toggles while another instance is transitioning --- apps/www/public/llms-full.txt | 7 ++++++- apps/www/public/r/animated-theme-toggler.json | 2 +- apps/www/registry/magicui/animated-theme-toggler.tsx | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index 2fcf8c537..b5f76890f 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3240,7 +3240,12 @@ export const AnimatedThemeToggler = ({ const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button || isTransitioningRef.current) return + if ( + !button || + isTransitioningRef.current || + document.documentElement.dataset.magicuiThemeVt === "active" + ) + return const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportHeight = window.visualViewport?.height ?? window.innerHeight diff --git a/apps/www/public/r/animated-theme-toggler.json b/apps/www/public/r/animated-theme-toggler.json index 0458db55c..5a8cb2026 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 (!button || isTransitioningRef.current) 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)\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(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)\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 2a3c657e3..905a3af9f 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -169,7 +169,12 @@ export const AnimatedThemeToggler = ({ const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button || isTransitioningRef.current) return + if ( + !button || + isTransitioningRef.current || + document.documentElement.dataset.magicuiThemeVt === "active" + ) + return const viewportWidth = window.visualViewport?.width ?? window.innerWidth const viewportHeight = window.visualViewport?.height ?? window.innerHeight From 611a7528674df9b2ea7c990ff26e1f6d3dd0c8c7 Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Sun, 19 Jul 2026 15:48:54 +0900 Subject: [PATCH 4/5] fix(animated-theme-toggler): silence rejection from the finished cleanup chain --- apps/www/public/llms-full.txt | 2 +- apps/www/public/r/animated-theme-toggler.json | 2 +- apps/www/registry/magicui/animated-theme-toggler.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index b5f76890f..de701bada 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3314,7 +3314,7 @@ export const AnimatedThemeToggler = ({ flushSync(applyTheme) }) if (typeof transition?.finished?.finally === "function") { - transition.finished.finally(cleanup) + transition.finished.finally(cleanup).catch(() => {}) } else { cleanup() } diff --git a/apps/www/public/r/animated-theme-toggler.json b/apps/www/public/r/animated-theme-toggler.json index 5a8cb2026..c07ea7f73 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)\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(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", "type": "registry:ui" } ], diff --git a/apps/www/registry/magicui/animated-theme-toggler.tsx b/apps/www/registry/magicui/animated-theme-toggler.tsx index 905a3af9f..6bffd9135 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -243,7 +243,7 @@ export const AnimatedThemeToggler = ({ flushSync(applyTheme) }) if (typeof transition?.finished?.finally === "function") { - transition.finished.finally(cleanup) + transition.finished.finally(cleanup).catch(() => {}) } else { cleanup() } From 1996eb2a553b502db1187d96389c2fafa6a3f90e Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Sun, 19 Jul 2026 15:51:19 +0900 Subject: [PATCH 5/5] style: remove worktree --- .claude/worktrees/fix+toggler-crash | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/fix+toggler-crash diff --git a/.claude/worktrees/fix+toggler-crash b/.claude/worktrees/fix+toggler-crash deleted file mode 160000 index 37f7285f1..000000000 --- a/.claude/worktrees/fix+toggler-crash +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 37f7285f1a899ca0dac15ce167198263c75bef49