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"
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
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"
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. |
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 />;
}