From b2d7992ca4c82c3d40baa0fc993fd58d7513831c Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Wed, 27 May 2026 20:01:47 +0900 Subject: [PATCH 1/3] feat(animated-theme-toggler): add controlled mode for next-themes --- apps/www/public/llms-full.txt | 55 +++++++++++++++++-- apps/www/public/llms.txt | 1 + ...imated-theme-toggler-next-themes-demo.json | 20 +++++++ apps/www/public/r/animated-theme-toggler.json | 2 +- apps/www/public/r/registry.json | 18 ++++++ apps/www/public/registry.json | 18 ++++++ apps/www/registry.json | 18 ++++++ apps/www/registry/__index__.tsx | 17 ++++++ ...nimated-theme-toggler-next-themes-demo.tsx | 18 ++++++ .../magicui/animated-theme-toggler.tsx | 31 +++++++++-- apps/www/registry/registry-examples.ts | 15 +++++ 11 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 apps/www/public/r/animated-theme-toggler-next-themes-demo.json create mode 100644 apps/www/registry/example/animated-theme-toggler-next-themes-demo.tsx diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index 6625eeef5..df1ee56f4 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -3091,6 +3091,13 @@ interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"butt variant?: TransitionVariant /** When true, the transition expands from the viewport center instead of the button center. */ fromCenter?: boolean + /** + * Controlled theme value. When provided, the parent owns persistence + * (e.g. `next-themes`) and this component will not write to localStorage. + */ + theme?: "light" | "dark" + /** Called on toggle. Pair with `theme` for controlled usage. */ + onThemeChange?: (theme: "light" | "dark") => void } function polygonCollapsed(cx: number, cy: number, vertexCount: number): string { @@ -3202,15 +3209,21 @@ export const AnimatedThemeToggler = ({ duration = 400, variant, fromCenter = false, + theme, + onThemeChange, ...props }: AnimatedThemeTogglerProps) => { const shape = variant ?? "circle" - const [isDark, setIsDark] = useState(false) + const isControlled = theme !== undefined + const [internalIsDark, setInternalIsDark] = useState(false) + const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) useEffect(() => { + if (isControlled) return + const updateTheme = () => { - setIsDark(document.documentElement.classList.contains("dark")) + setInternalIsDark(document.documentElement.classList.contains("dark")) } updateTheme() @@ -3222,7 +3235,7 @@ export const AnimatedThemeToggler = ({ }) return () => observer.disconnect() - }, []) + }, [isControlled]) const toggleTheme = useCallback(() => { const button = buttonRef.current @@ -3249,9 +3262,15 @@ export const AnimatedThemeToggler = ({ const applyTheme = () => { const newTheme = !isDark - setIsDark(newTheme) + // Always toggle the class synchronously so the View Transitions API + // snapshots the new theme inside the startViewTransition callback. document.documentElement.classList.toggle("dark") - localStorage.setItem("theme", newTheme ? "dark" : "light") + if (isControlled) { + onThemeChange?.(newTheme ? "dark" : "light") + } else { + setInternalIsDark(newTheme) + localStorage.setItem("theme", newTheme ? "dark" : "light") + } } if (typeof document.startViewTransition !== "function") { @@ -3309,7 +3328,7 @@ export const AnimatedThemeToggler = ({ ) }) } - }, [shape, fromCenter, duration, isDark]) + }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) return ( \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\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", "type": "registry:ui" } ], diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index ce7754ff6..499944e16 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -3754,6 +3754,24 @@ } ] }, + { + "name": "animated-theme-toggler-next-themes-demo", + "type": "registry:example", + "title": "Animated Theme Toggler — next-themes", + "description": "Controlled usage with next-themes so useTheme() subscribers stay in sync on toggle.", + "dependencies": [ + "next-themes" + ], + "registryDependencies": [ + "@magicui/animated-theme-toggler" + ], + "files": [ + { + "path": "registry/example/animated-theme-toggler-next-themes-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "light-rays-demo", "type": "registry:example", diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index ce7754ff6..499944e16 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -3754,6 +3754,24 @@ } ] }, + { + "name": "animated-theme-toggler-next-themes-demo", + "type": "registry:example", + "title": "Animated Theme Toggler — next-themes", + "description": "Controlled usage with next-themes so useTheme() subscribers stay in sync on toggle.", + "dependencies": [ + "next-themes" + ], + "registryDependencies": [ + "@magicui/animated-theme-toggler" + ], + "files": [ + { + "path": "registry/example/animated-theme-toggler-next-themes-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "light-rays-demo", "type": "registry:example", diff --git a/apps/www/registry.json b/apps/www/registry.json index ce7754ff6..499944e16 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -3754,6 +3754,24 @@ } ] }, + { + "name": "animated-theme-toggler-next-themes-demo", + "type": "registry:example", + "title": "Animated Theme Toggler — next-themes", + "description": "Controlled usage with next-themes so useTheme() subscribers stay in sync on toggle.", + "dependencies": [ + "next-themes" + ], + "registryDependencies": [ + "@magicui/animated-theme-toggler" + ], + "files": [ + { + "path": "registry/example/animated-theme-toggler-next-themes-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "light-rays-demo", "type": "registry:example", diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx index c660e626b..8c1517ae8 100644 --- a/apps/www/registry/__index__.tsx +++ b/apps/www/registry/__index__.tsx @@ -3963,6 +3963,23 @@ export const Index: Record = { }), meta: undefined, }, + "animated-theme-toggler-next-themes-demo": { + name: "animated-theme-toggler-next-themes-demo", + description: "Controlled usage with next-themes so useTheme() subscribers stay in sync on toggle.", + type: "registry:example", + registryDependencies: ["@magicui/animated-theme-toggler"], + files: [{ + path: "registry/example/animated-theme-toggler-next-themes-demo.tsx", + type: "registry:example", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/example/animated-theme-toggler-next-themes-demo.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name + return { default: mod.default ?? mod[exportName] } + }), + meta: undefined, + }, "light-rays-demo": { name: "light-rays-demo", description: "Demo of the light-rays component showcasing animated light rays", diff --git a/apps/www/registry/example/animated-theme-toggler-next-themes-demo.tsx b/apps/www/registry/example/animated-theme-toggler-next-themes-demo.tsx new file mode 100644 index 000000000..f0d701ce9 --- /dev/null +++ b/apps/www/registry/example/animated-theme-toggler-next-themes-demo.tsx @@ -0,0 +1,18 @@ +"use client" + +import { useTheme } from "next-themes" + +import { AnimatedThemeToggler } from "@/registry/magicui/animated-theme-toggler" + +export default function AnimatedThemeTogglerNextThemesDemo() { + const { resolvedTheme, setTheme } = useTheme() + + return ( +
+ +
+ ) +} diff --git a/apps/www/registry/magicui/animated-theme-toggler.tsx b/apps/www/registry/magicui/animated-theme-toggler.tsx index 60b189e98..7b2649b39 100644 --- a/apps/www/registry/magicui/animated-theme-toggler.tsx +++ b/apps/www/registry/magicui/animated-theme-toggler.tsx @@ -20,6 +20,13 @@ interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"butt variant?: TransitionVariant /** When true, the transition expands from the viewport center instead of the button center. */ fromCenter?: boolean + /** + * Controlled theme value. When provided, the parent owns persistence + * (e.g. `next-themes`) and this component will not write to localStorage. + */ + theme?: "light" | "dark" + /** Called on toggle. Pair with `theme` for controlled usage. */ + onThemeChange?: (theme: "light" | "dark") => void } function polygonCollapsed(cx: number, cy: number, vertexCount: number): string { @@ -131,15 +138,21 @@ export const AnimatedThemeToggler = ({ duration = 400, variant, fromCenter = false, + theme, + onThemeChange, ...props }: AnimatedThemeTogglerProps) => { const shape = variant ?? "circle" - const [isDark, setIsDark] = useState(false) + const isControlled = theme !== undefined + const [internalIsDark, setInternalIsDark] = useState(false) + const isDark = isControlled ? theme === "dark" : internalIsDark const buttonRef = useRef(null) useEffect(() => { + if (isControlled) return + const updateTheme = () => { - setIsDark(document.documentElement.classList.contains("dark")) + setInternalIsDark(document.documentElement.classList.contains("dark")) } updateTheme() @@ -151,7 +164,7 @@ export const AnimatedThemeToggler = ({ }) return () => observer.disconnect() - }, []) + }, [isControlled]) const toggleTheme = useCallback(() => { const button = buttonRef.current @@ -178,9 +191,15 @@ export const AnimatedThemeToggler = ({ const applyTheme = () => { const newTheme = !isDark - setIsDark(newTheme) + // Always toggle the class synchronously so the View Transitions API + // snapshots the new theme inside the startViewTransition callback. document.documentElement.classList.toggle("dark") - localStorage.setItem("theme", newTheme ? "dark" : "light") + if (isControlled) { + onThemeChange?.(newTheme ? "dark" : "light") + } else { + setInternalIsDark(newTheme) + localStorage.setItem("theme", newTheme ? "dark" : "light") + } } if (typeof document.startViewTransition !== "function") { @@ -238,7 +257,7 @@ export const AnimatedThemeToggler = ({ ) }) } - }, [shape, fromCenter, duration, isDark]) + }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange]) return (