Theme Toggle
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"
className="h-9 w-9"
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(
"h-9 cursor-pointer transition-colors",
simple || isMobile ? "w-auto px-3" : "w-9",
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
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/theme-toggle.json"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"
className="h-9 w-9"
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(
"h-9 cursor-pointer transition-colors",
simple || isMobile ? "w-auto px-3" : "w-9",
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
| 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. |
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 />;
}