UI

Mode Toggler

A minimal Sun ↔ Moon theme toggle button built on next-themes. Supports an optional click sound, composable className, and dynamic aria-label.

Installation

Mode Toggler is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/mode-toggler

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { ModeToggler } from "@/components/ui/mode-toggler";
 
export function Header() {
  return (
    <header className="flex items-center justify-between px-4 h-12">
      <span>my app</span>
      <ModeToggler />
    </header>
  );
}

Props

PropTypeDefaultNotes
classNamestring—Extra classes forwarded to the <Button>. Useful for sizing, rounding, or colour overrides when embedding inside a nav strip.
audioSrcstring—URL of an audio clip that plays on each toggle. Lazy-instantiated — no network request until the prop is first provided.
audioDelaynumber200ms between the audio starting and the theme switching. Only active when audioSrc is set.

Notes & Features

  • next-themes integration. Uses resolvedTheme so the correct icon renders on first paint — no flash with OS dark mode.
  • Accessible. aria-label updates dynamically: "switch to light mode" in dark, "switch to dark mode" in light.
  • Composable. Pass className to strip the border/rounding for embedding in a pill group or navbar strip.
  • Audio optional. Omit audioSrc for a silent toggle; pass a .mp3 or .ogg URL to match the site's own click sound.

Setup notes

The toggle reads and writes the next-themes context, so your root layout has to wrap children with ThemeProvider:

// app/layout.tsx
import { ThemeProvider } from "next-themes";
 
export default function RootLayout({ children }) {
  return (
    <html suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Manual installation

Rather not use the CLI? Everything Mode Toggler needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install lucide-react next-themes

shadcn primitives

$npx shadcn@latest add button

Source

components/ui/mode-toggler.tsx62 lines
"use client";

import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";

interface ModeTogglerProps {
  /** Extra classes applied to the button wrapper. */
  className?: string;
  /** Play an audio clip when the theme switches. Pass a public URL. */
  audioSrc?: string;
  /** Delay (ms) between the audio playing and the theme switching. Default 200. */
  audioDelay?: number;
}

/**
 * A minimal Sun <-> Moon theme toggle button.
 * Reads and writes next-themes resolved theme. Works with class-based dark mode.
 */
export function ModeToggler({
  className,
  audioSrc,
  audioDelay = 200,
}: ModeTogglerProps) {
  const { setTheme, resolvedTheme } = useTheme();
  const audioRef = useRef<HTMLAudioElement | null>(null);

  useEffect(() => {
    if (audioSrc) {
      audioRef.current = new Audio(audioSrc);
    }
  }, [audioSrc]);

  const toggle = useCallback(() => {
    if (audioRef.current) {
      audioRef.current.currentTime = 0;
      audioRef.current.play().catch(() => {});
    }
    const next = resolvedTheme === "dark" ? "light" : "dark";
    if (audioRef.current) {
      setTimeout(() => setTheme(next), audioDelay);
    } else {
      setTheme(next);
    }
  }, [resolvedTheme, setTheme, audioDelay]);

  return (
    <Button
      variant="outline"
      size="icon"
      className={className}
      onClick={toggle}
      aria-label={
        resolvedTheme === "dark" ? "switch to light mode" : "switch to dark mode"
      }
    >
      <Sun className="h-[1.1rem] w-[1.1rem] fill-yellow-400 rotate-0 scale-100 transition-transform duration-300 dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-[1.1rem]  fill-gray-400 rotate-90 scale-0 transition-transform duration-300 dark:rotate-0 dark:scale-100" />
    </Button>
  );
}