Theme Toggle for React
Accessible dark mode toggle with View Transitions API support. Icon, icon-label, or dual-tab variants.
Component
Icon
Icon with label
Dual tabs
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener("change", listener);
return () =>
media.removeEventListener("change", listener);
}, [query]);
return matches;
}
interface ThemeToggleProps {
simple?: boolean;
dual?: boolean;
className?: string;
useViewTransition?: boolean;
children?: (theme: {
theme: "light" | "dark";
toggleTheme: () => void;
}) => React.ReactNode;
}
export function ThemeToggle({
simple,
dual,
className,
useViewTransition = true,
children,
}: ThemeToggleProps) {
const [mounted, setMounted] = useState(false);
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
const [missingProvider, setMissingProvider] =
useState(false);
const { setTheme, resolvedTheme } = useTheme();
const tabsId = useId();
const isMobile = useMediaQuery("(max-width: 768px)");
useEffect(() => {
setMounted(true);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control" || e.key === "Meta") {
setIsCtrlPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control" || e.key === "Meta") {
setIsCtrlPressed(false);
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener(
"keydown",
handleKeyDown,
);
document.removeEventListener("keyup", handleKeyUp);
};
}, []);
useEffect(() => {
if (!mounted) {
return;
}
const id = setTimeout(() => {
if (resolvedTheme === undefined) {
setMissingProvider(true);
if (process.env.NODE_ENV === "development") {
console.warn(
"[ThemeToggle] ThemeProvider not found. Wrap your app with ThemeProvider from next-themes. See: https://pulkitxm.com/components/theme-toggle",
);
}
}
}, 200);
return () => clearTimeout(id);
}, [mounted, resolvedTheme]);
const handleThemeToggle = async (
_newTheme?: "light" | "dark",
) => {
if (!useViewTransition || isCtrlPressed) {
setTheme(
_newTheme ??
(resolvedTheme === "dark" ? "light" : "dark"),
);
return;
}
const newTheme =
_newTheme ??
(resolvedTheme === "dark" ? "light" : "dark");
const update = () => {
setTheme(newTheme);
};
if (
typeof document !== "undefined" &&
"startViewTransition" in document &&
newTheme !== resolvedTheme
) {
try {
await new Promise((resolve) =>
setTimeout(resolve, 50),
);
document.documentElement.style.viewTransitionName =
"theme-transition";
await document.startViewTransition(update).finished;
document.documentElement.style.viewTransitionName =
"";
} catch (error) {
console.error("Failed to transition", error);
update();
}
} else {
setTimeout(() => {
update();
}, 50);
}
};
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
aria-label="Loading theme toggle"
suppressHydrationWarning={true}
>
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
</Button>
);
}
if (missingProvider) {
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-amber-700 dark:text-amber-400",
className,
)}
role="alert"
>
<Sun
className="h-4 w-4 shrink-0"
aria-hidden={true}
/>
<span className="font-medium text-xs">
ThemeProvider required. Wrap your app with{" "}
<code className="rounded bg-amber-500/20 px-1 py-0.5 font-mono text-[10px]">
ThemeProvider
</code>{" "}
from next-themes.
</span>
</div>
);
}
return (
<>
{dual ? (
<div className="w-fit">
<div
className="inline-flex gap-1 rounded-lg border border-border bg-muted/80 p-1 shadow-sm dark:bg-muted/50"
role="tablist"
aria-label="Theme selection"
>
<button
type="button"
role="tab"
aria-selected={
resolvedTheme === "light" ? "true" : "false"
}
id={`theme-toggle-light-${tabsId}`}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 py-1.5 font-medium text-xs transition-[background-color,border-color,color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
resolvedTheme === "light"
? "bg-background text-foreground shadow-sm ring-1 ring-border/50"
: "text-foreground/70 hover:bg-background/50 hover:text-foreground",
)}
onClick={() => handleThemeToggle("light")}
aria-label="Switch to light theme"
>
<Sun
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>{" "}
Light
</button>
<button
type="button"
role="tab"
aria-selected={
resolvedTheme === "dark" ? "true" : "false"
}
id={`theme-toggle-dark-${tabsId}`}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 py-1.5 font-medium text-xs transition-[background-color,border-color,color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
resolvedTheme === "dark"
? "bg-background text-foreground shadow-sm ring-1 ring-border/50"
: "text-foreground/70 hover:bg-background/50 hover:text-foreground",
)}
onClick={() => handleThemeToggle("dark")}
aria-label="Switch to dark theme"
>
<Moon
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>{" "}
Dark
</button>
</div>
</div>
) : (
<Button
variant="ghost"
size="icon"
onClick={() =>
handleThemeToggle(
resolvedTheme === "dark" ? "light" : "dark",
)
}
className={cn(
"cursor-pointer transition-colors",
simple || isMobile ? "w-auto px-3" : "",
simple
? "flex items-center gap-2"
: "hover:bg-gray-200 dark:hover:bg-gray-700",
className,
)}
aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
>
{resolvedTheme === "dark" ? (
<Moon
className="h-4 w-4 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
) : (
<Sun
className="h-4 w-4 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
)}
{simple &&
(resolvedTheme === "dark" ? "Light" : "Dark")}
</Button>
)}
{children?.({
theme: resolvedTheme === "dark" ? "dark" : "light",
toggleTheme: () => handleThemeToggle(),
})}
</>
);
}"use client";
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from "next-themes";
export function ThemeProvider({
children,
...props
}: ThemeProviderProps) {
return (
<div suppressHydrationWarning={true}>
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem={true}
disableTransitionOnChange={true}
storageKey="theme"
enableColorScheme={true}
{...props}
>
{children}
</NextThemesProvider>
</div>
);
}@keyframes theme-toggle-slide-in {
from {
clip-path: inset(0 0 100% 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@supports (view-transition-name: theme-transition) {
::view-transition-new(theme-transition) {
clip-path: inset(0 0 100% 0);
animation: theme-toggle-slide-in 0.6s forwards linear;
will-change: clip-path;
pointer-events: none;
}
::view-transition-old(theme-transition) {
animation: none;
pointer-events: none;
}
::view-transition {
pointer-events: none;
}
}Installation
After running the command
Complete these steps to finish the setup:
1. Import theme transition CSS in globals.css
/* Add to app/globals.css: */
@import "./theme-toggle.css";2. Wrap app with ThemeProvider
import { ThemeProvider } from "@/providers/theme-provider";
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
>
{children}
</ThemeProvider>
</body>
</html>
);
}3. Import and use
import { ThemeToggle } from "@/components/theme-toggle";
<ThemeToggle />;1. Install dependencies
pnpm add next-themes lucide-react2. Copy theme-provider, theme-toggle, and theme-toggle.css
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener("change", listener);
return () =>
media.removeEventListener("change", listener);
}, [query]);
return matches;
}
interface ThemeToggleProps {
simple?: boolean;
dual?: boolean;
className?: string;
useViewTransition?: boolean;
children?: (theme: {
theme: "light" | "dark";
toggleTheme: () => void;
}) => React.ReactNode;
}
export function ThemeToggle({
simple,
dual,
className,
useViewTransition = true,
children,
}: ThemeToggleProps) {
const [mounted, setMounted] = useState(false);
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
const [missingProvider, setMissingProvider] =
useState(false);
const { setTheme, resolvedTheme } = useTheme();
const tabsId = useId();
const isMobile = useMediaQuery("(max-width: 768px)");
useEffect(() => {
setMounted(true);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control" || e.key === "Meta") {
setIsCtrlPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control" || e.key === "Meta") {
setIsCtrlPressed(false);
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener(
"keydown",
handleKeyDown,
);
document.removeEventListener("keyup", handleKeyUp);
};
}, []);
useEffect(() => {
if (!mounted) {
return;
}
const id = setTimeout(() => {
if (resolvedTheme === undefined) {
setMissingProvider(true);
if (process.env.NODE_ENV === "development") {
console.warn(
"[ThemeToggle] ThemeProvider not found. Wrap your app with ThemeProvider from next-themes. See: https://pulkitxm.com/components/theme-toggle",
);
}
}
}, 200);
return () => clearTimeout(id);
}, [mounted, resolvedTheme]);
const handleThemeToggle = async (
_newTheme?: "light" | "dark",
) => {
if (!useViewTransition || isCtrlPressed) {
setTheme(
_newTheme ??
(resolvedTheme === "dark" ? "light" : "dark"),
);
return;
}
const newTheme =
_newTheme ??
(resolvedTheme === "dark" ? "light" : "dark");
const update = () => {
setTheme(newTheme);
};
if (
typeof document !== "undefined" &&
"startViewTransition" in document &&
newTheme !== resolvedTheme
) {
try {
await new Promise((resolve) =>
setTimeout(resolve, 50),
);
document.documentElement.style.viewTransitionName =
"theme-transition";
await document.startViewTransition(update).finished;
document.documentElement.style.viewTransitionName =
"";
} catch (error) {
console.error("Failed to transition", error);
update();
}
} else {
setTimeout(() => {
update();
}, 50);
}
};
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
aria-label="Loading theme toggle"
suppressHydrationWarning={true}
>
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
</Button>
);
}
if (missingProvider) {
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-amber-700 dark:text-amber-400",
className,
)}
role="alert"
>
<Sun
className="h-4 w-4 shrink-0"
aria-hidden={true}
/>
<span className="font-medium text-xs">
ThemeProvider required. Wrap your app with{" "}
<code className="rounded bg-amber-500/20 px-1 py-0.5 font-mono text-[10px]">
ThemeProvider
</code>{" "}
from next-themes.
</span>
</div>
);
}
return (
<>
{dual ? (
<div className="w-fit">
<div
className="inline-flex gap-1 rounded-lg border border-border bg-muted/80 p-1 shadow-sm dark:bg-muted/50"
role="tablist"
aria-label="Theme selection"
>
<button
type="button"
role="tab"
aria-selected={
resolvedTheme === "light" ? "true" : "false"
}
id={`theme-toggle-light-${tabsId}`}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 py-1.5 font-medium text-xs transition-[background-color,border-color,color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
resolvedTheme === "light"
? "bg-background text-foreground shadow-sm ring-1 ring-border/50"
: "text-foreground/70 hover:bg-background/50 hover:text-foreground",
)}
onClick={() => handleThemeToggle("light")}
aria-label="Switch to light theme"
>
<Sun
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>{" "}
Light
</button>
<button
type="button"
role="tab"
aria-selected={
resolvedTheme === "dark" ? "true" : "false"
}
id={`theme-toggle-dark-${tabsId}`}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 py-1.5 font-medium text-xs transition-[background-color,border-color,color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
resolvedTheme === "dark"
? "bg-background text-foreground shadow-sm ring-1 ring-border/50"
: "text-foreground/70 hover:bg-background/50 hover:text-foreground",
)}
onClick={() => handleThemeToggle("dark")}
aria-label="Switch to dark theme"
>
<Moon
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>{" "}
Dark
</button>
</div>
</div>
) : (
<Button
variant="ghost"
size="icon"
onClick={() =>
handleThemeToggle(
resolvedTheme === "dark" ? "light" : "dark",
)
}
className={cn(
"cursor-pointer transition-colors",
simple || isMobile ? "w-auto px-3" : "",
simple
? "flex items-center gap-2"
: "hover:bg-gray-200 dark:hover:bg-gray-700",
className,
)}
aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
>
{resolvedTheme === "dark" ? (
<Moon
className="h-4 w-4 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
) : (
<Sun
className="h-4 w-4 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
)}
{simple &&
(resolvedTheme === "dark" ? "Light" : "Dark")}
</Button>
)}
{children?.({
theme: resolvedTheme === "dark" ? "dark" : "light",
toggleTheme: () => handleThemeToggle(),
})}
</>
);
}3. Import theme transition CSS in globals.css
/* Add to app/globals.css: */
@import "./theme-toggle.css";4. Wrap app with ThemeProvider
import { ThemeProvider } from "@/providers/theme-provider";
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
>
{children}
</ThemeProvider>
</body>
</html>
);
}5. Import and use
import { ThemeToggle } from "@/components/theme-toggle";
<ThemeToggle />;Usage
Import the component
Add the ThemeToggle import to your file.
import { ThemeToggle } from "@/components/theme-toggle";Use with default props
Use the default icon-only toggle in your navbar or header.
<ThemeToggle />;Customize with props
Use simple for icon + label, or dual for tab-style selection.
<ThemeToggle simple />;Guidelines
- Wrap your app with ThemeProvider from providers/theme-provider before using ThemeToggle. Place it inside <body>, not wrapping it.
- For Tailwind v4, add @custom-variant dark (&:is(.dark *)); to globals.css for class-based dark mode.
- ThemeProvider must be inside body. Add suppressHydrationWarning to the html tag.
- For smooth theme transitions, import theme-toggle.css in globals.css.
- Hold Ctrl or Cmd while clicking to skip the view transition (useful for testing).
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| simple | boolean | false | Show icon with Light/Dark label next to it. |
| dual | boolean | false | Render as dual-tab layout with Light and Dark options. |
| className | string | undefined | Additional CSS classes for the toggle. |
| useViewTransition | boolean | true | Use View Transitions API for theme switch animation when supported. |
| children | (theme: { theme: 'light' | 'dark'; toggleTheme: () => void }) => React.ReactNode | undefined | Render prop receiving { theme, toggleTheme } for custom UI. |
Accessibility
- All variants use native button elements, so they are focusable and operable with Enter and Space out of the box.
- Every control carries a descriptive aria-label such as Switch to light mode, decorative Sun and Moon icons are marked aria-hidden, and the loading skeleton announces Loading theme toggle while the missing-provider notice uses role alert.
- The dual variant exposes role tablist on the container and role tab with aria-selected on each option so assistive tech reports the active theme.
- Dual-tab buttons have explicit focus-visible ring styles, while the icon button inherits focus styles from the shared Button component.
- The view-transition clip-path animation and the View Transitions API call are not gated by prefers-reduced-motion, so they should be wrapped with a prefers-reduced-motion media query to respect users who request reduced motion.
Performance
- The theme switch animates only clip-path on the view-transition pseudo-element and declares will-change clip-path, while the dual tabs transition just background-color, border-color, and color, all of which avoid layout reflow.
- It uses the native View Transitions API behind a startViewTransition feature check and clears viewTransitionName after the transition finished promise resolves, falling back to a plain setTheme when unsupported.
- Keydown and keyup listeners on document, the matchMedia change listener, and the missing-provider setTimeout are all cleaned up in their effect teardowns, preventing leaks.
- No requestAnimationFrame or IntersectionObserver is used; small 50ms setTimeout delays sequence the transition, and animations are not gated by reduced-motion or element visibility.
Examples
Icon
Icon-only toggle. Compact for navbars and headers.
Icon
Icon with label
Dual tabs
import { ThemeToggle } from "@/components/theme-toggle";
export function ThemeToggleBasic() {
return <ThemeToggle />;
}Icon with label
Icon with Light/Dark label. Useful for mobile or settings.
Icon
Icon with label
Dual tabs
import { ThemeToggle } from "@/components/theme-toggle";
export function ThemeToggleWithLabel() {
return <ThemeToggle simple />;
}Dual tabs
Dual-tab layout with Light and Dark options side by side.
Icon
Icon with label
Dual tabs
import { ThemeToggle } from "@/components/theme-toggle";
export function ThemeToggleDual() {
return <ThemeToggle dual />;
}Related reading
- Smooth Operators — Transitioning colors when the theme flips
- Swap Button for React — Another state-swapping micro-interaction
Last updated on Jun 19