magicui icon indicating copy to clipboard operation
magicui copied to clipboard

[bug]: animated theme toggler doesnt update next-themes

Open neldivad opened this issue 5 months ago • 1 comments

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

neldivad avatar Nov 06 '25 19:11 neldivad

propose to use the updated script for AnimatedThemeToggler.tsx

neldivad avatar Nov 06 '25 19:11 neldivad

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>
  );
};

amuif avatar Dec 02 '25 09:12 amuif