magicui
magicui copied to clipboard
[bug]: animated theme toggler doesnt update next-themes
Describe the bug
AnimatedThemeToggler was bypassing next-themes by directly manipulating the DOM
(document.documentElement.classList.toggle("dark"))
Directly setting localStorage (localStorage.setItem("theme", ...)) and not using setTheme from next-themes
Original:
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [])
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
document.documentElement.classList.toggle("dark")
localStorage.setItem("theme", newTheme ? "dark" : "light")
})
}).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}, [isDark, duration])
return (
<button
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{isDark ? <Sun /> : <Moon />}
<span className="sr-only">Toggle theme</span>
</button>
)
}
Updated:
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const { resolvedTheme, setTheme } = useTheme()
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
setIsDark(resolvedTheme === "dark")
}, [resolvedTheme])
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
const newTheme = resolvedTheme === "dark" ? "light" : "dark"
await document.startViewTransition(() => {
flushSync(() => {
setIsDark(newTheme === "dark")
// Use next-themes setTheme instead of direct DOM manipulation
setTheme(newTheme)
})
}).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}, [resolvedTheme, setTheme, duration])
return (
<button
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{isDark ? <Sun /> : <Moon />}
<span className="sr-only">Toggle theme</span>
</button>
)
}
Affected component/components
animated theme toggler
How to reproduce
// src/providers/theme-provider.tsx
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
// src/app/layout.tsx
import { ThemeProvider as TP } from "@/components/providers/theme-provider";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning className="h-full">
<body
className={`${inter.className} antialiased h-full min-h-screen`}
>
<TP
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
storageKey="cwa-notion-theme"
>
{children}
</TP>
</body>
</html>
);
}
File that tries to use next themes to update css
// src/components/editor.tsx
"use client"
import {
BlockNoteEditor,
PartialBlock
} from "@blocknote/core"
import {
useCreateBlockNote
} from "@blocknote/react"
import { BlockNoteView } from "@blocknote/shadcn"
// Default styles for the shadcn editor
import "@blocknote/shadcn/style.css";
// Include the included Inter font
import "@blocknote/core/fonts/inter.css";
import { useTheme } from "next-themes";
import { useEdgeStore } from "@/lib/edgestore"
//--------------
interface EditorProps {
onChange?: (value: string) => void;
initialContent?: string;
editable?: boolean
}
const Editor = ({
onChange,
initialContent,
editable
}: EditorProps) => {
const { resolvedTheme } = useTheme(); <<<<<<<<<<<<<<<
const { edgestore } = useEdgeStore();
const handleUpload = async (file: File) => {
const response = await edgestore.publicFiles.upload({
file
});
return response.url;
}
const editor: BlockNoteEditor = useCreateBlockNote({
initialContent:
initialContent
? JSON.parse(initialContent) as PartialBlock[]
: undefined,
uploadFile: handleUpload,
})
const isEditable = editable ?? true;
return (
<BlockNoteView
editor={editor}
editable={isEditable}
theme={resolvedTheme === "dark" ? "dark" : "light"} <<<<<<<<<<<
onChange={isEditable && onChange ? () => {
onChange(JSON.stringify(editor.document, null, 2));
} : undefined}
/>
);
}
export default Editor;
Codesandbox/StackBlitz link
No response
Logs
System Info
Processor AMD Ryzen 5 3600 6-Core Processor 3.59 GHz
Installed RAM 32.0 GB
Storage 932 GB HDD WDC WD10EZEX-08WN4A0, 954 GB SSD Samsung SSD 870 EVO 1TB, 477 GB SSD ADATA SX8200PNP
Graphics Card NVIDIA GeForce GTX 1650 (4 GB)
System Type 64-bit operating system, x64-based processor
Pen and touch No pen or touch input is available for this display
Before submitting
- [x] I've made research efforts and searched the documentation
- [x] I've searched for existing issues
propose to use the updated script for AnimatedThemeToggler.tsx
I tried using your updated code but it didn't update the theme do i make it like this it tweaks the theme is the startViewTransition is not supported (especially in Firefox based browsers and including mobiles) and if it's available just proceed as usual.
"use client";
import { Moon, SunDim } from "lucide-react";
import { useRef } from "react";
import { flushSync } from "react-dom";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { useTheme } from "next-themes";
type Props = {
className?: string;
};
export const AnimatedThemeToggler = ({ className }: Props) => {
const { theme, setTheme } = useTheme();
const buttonRef = useRef<HTMLButtonElement | null>(null);
const changeTheme = async () => {
if (!buttonRef.current) return;
const doToggle = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
return newTheme;
};
if (!document.startViewTransition) {
doToggle();
return;
}
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
if (newTheme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
});
}).ready;
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const y = top + height / 2;
const x = left + width / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRad = Math.hypot(Math.max(left, right), Math.max(top, bottom));
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRad}px at ${x}px ${y}px)`,
],
},
{
duration: 700,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
};
return (
<Button
variant="ghost"
ref={buttonRef}
onClick={changeTheme}
className={cn("size-12 rounded-full", className)}
aria-label="Toggle theme"
>
{theme === "dark" ? <SunDim /> : <Moon />}
</Button>
);
};