diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index c84bf080e..de701bada 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3218,6 +3218,7 @@ export const AnimatedThemeToggler = ({ const [internalIsDark, setInternalIsDark] = useState(false) const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) + const isTransitioningRef = useRef(false) useEffect(() => { if (isControlled) return @@ -3239,7 +3240,12 @@ export const AnimatedThemeToggler = ({ const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button) 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 @@ -3297,36 +3303,40 @@ export const AnimatedThemeToggler = ({ // 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") } + isTransitioningRef.current = true const transition = document.startViewTransition(() => { flushSync(applyTheme) }) if (typeof transition?.finished?.finally === "function") { - transition.finished.finally(cleanup) + transition.finished.finally(cleanup).catch(() => {}) } 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(() => { + 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)", + } + ) + }) + .catch(() => {}) } }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) diff --git a/apps/www/public/r/animated-theme-toggler.json b/apps/www/public/r/animated-theme-toggler.json index 0bd9dd21c..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\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 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 7b2649b39..6bffd9135 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -147,6 +147,7 @@ export const AnimatedThemeToggler = ({ const [internalIsDark, setInternalIsDark] = useState(false) const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) + const isTransitioningRef = useRef(false) useEffect(() => { if (isControlled) return @@ -168,7 +169,12 @@ export const AnimatedThemeToggler = ({ const toggleTheme = useCallback(() => { const button = buttonRef.current - if (!button) 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 @@ -226,36 +232,40 @@ export const AnimatedThemeToggler = ({ // 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") } + isTransitioningRef.current = true const transition = document.startViewTransition(() => { flushSync(applyTheme) }) if (typeof transition?.finished?.finally === "function") { - transition.finished.finally(cleanup) + transition.finished.finally(cleanup).catch(() => {}) } 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(() => { + 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)", + } + ) + }) + .catch(() => {}) } }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange])