UI

Theme switcher

A visual Light, Dark, and System theme selector with textured previews, an animated selection outline, and keyboard navigation.

Props

Disabled

Installation

Theme switcher 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/theme-switcher

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 { ThemeSwitcher } from "@/components/ui/theme-switcher";
 
export function AppearanceSettings() {
  return <ThemeSwitcher />;
}

Props

PropTypeDefaultNotes
classNamestringNoneExtra classes on the responsive three-column selector.
disabledbooleanfalseDisables all three options and their interactions.

Setup

Wrap your app in the next-themes provider. If your app already has a provider, reuse it.

// components/theme-provider.tsx
"use client";
 
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
 
export function ThemeProvider({ children }: { children: ReactNode }) {
  return (
    <NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </NextThemesProvider>
  );
}

Use that client provider in your root layout and add suppressHydrationWarning to <html>:

// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
import type { ReactNode } from "react";
 
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Interaction

  • Click a preview to select Light, Dark, or System. The selection outline slides to the new option and the site theme changes immediately.
  • Tab enters the selected option. Arrow keys move focus and selection, wrapping at either end. Home and End select the first and last options. Space or Enter selects the focused option. Tab then leaves the group.
  • A focused preview lifts slightly and shows a visible keyboard focus outline. Reduced motion removes movement.
  • System follows the device color preference while keeping System selected. next-themes saves the choice and synchronizes it across tabs.
  • The preview artwork stays fixed in every theme. Multiple instances have independent animation and SVG identifiers.
  • Options stay disabled until hydration completes, without changing the selector's size. A provider's forcedTheme also disables switching.

Manual installation

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

Dependencies

$npm install motion next-themes

Source

components/ui/theme-switcher.tsx184 lines
"use client";

import { motion, useReducedMotion } from "motion/react";
import { useTheme } from "next-themes";
import { useId, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent } from "react";

import { cn } from "@/lib/utils";

const OPTIONS = [
  { value: "light", label: "Light" },
  { value: "dark", label: "Dark" },
  { value: "system", label: "System" },
] as const;

type ThemeValue = (typeof OPTIONS)[number]["value"];

export interface ThemeSwitcherProps {
  /** Extra classes applied to the three-column selector. */
  className?: string;
  /** Disable all theme changes. Forced themes also disable the selector. */
  disabled?: boolean;
}

const subscribe = () => () => {};
const getSnapshot = () => true;
const getServerSnapshot = () => false;

/** A visual Light / Dark / System selector backed by next-themes. */
export function ThemeSwitcher({ className, disabled = false }: ThemeSwitcherProps) {
  const { theme, setTheme, forcedTheme } = useTheme();
  const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  const reduceMotion = useReducedMotion();
  const id = useId();
  const buttons = useRef<Array<HTMLButtonElement | null>>([]);
  const [focused, setFocused] = useState<ThemeValue | null>(null);
  const activeTheme = mounted ? forcedTheme || theme : undefined;
  const activeIndex = OPTIONS.findIndex((option) => option.value === activeTheme);
  const unavailable = disabled || !mounted || Boolean(forcedTheme);
  const transition = reduceMotion
    ? { duration: 0 }
    : { type: "spring" as const, duration: 0.26, bounce: 0.12 };

  function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    let nextIndex: number;

    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        nextIndex = (index + 1) % OPTIONS.length;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        nextIndex = (index - 1 + OPTIONS.length) % OPTIONS.length;
        break;
      case "Home":
        nextIndex = 0;
        break;
      case "End":
        nextIndex = OPTIONS.length - 1;
        break;
      default:
        return;
    }

    event.preventDefault();
    setTheme(OPTIONS[nextIndex].value);
    buttons.current[nextIndex]?.focus();
  }

  return (
    <div
      role="radiogroup"
      aria-label="Theme"
      aria-disabled={unavailable}
      className={cn("grid w-full max-w-[26rem] grid-cols-3 gap-3", className)}
    >
      {OPTIONS.map((option, index) => {
        const selected = activeTheme === option.value;

        return (
          <motion.button
            key={option.value}
            ref={(button) => { buttons.current[index] = button; }}
            type="button"
            role="radio"
            aria-checked={selected}
            disabled={unavailable}
            tabIndex={index === (activeIndex < 0 ? 0 : activeIndex) ? 0 : -1}
            onClick={() => setTheme(option.value)}
            onKeyDown={(event) => handleKeyDown(event, index)}
            onFocus={() => setFocused(option.value)}
            onBlur={() => setFocused(null)}
            animate={{ y: focused === option.value && !reduceMotion ? -2 : 0 }}
            whileHover={unavailable || reduceMotion ? undefined : { y: -2 }}
            whileTap={unavailable || reduceMotion ? undefined : { scale: 0.97 }}
            transition={transition}
            className="group min-w-0 cursor-pointer rounded-xl outline-none disabled:cursor-not-allowed disabled:opacity-50"
          >
            <span
              className={cn(
                "relative block rounded-xl group-focus-visible:outline-2 group-focus-visible:outline-offset-4 group-focus-visible:outline-ring",
                !selected && "ring-1 ring-inset ring-border",
              )}
            >
              <ThemePreview theme={option.value} id={`${id}-${option.value}`} />
              {selected && (
                <motion.span
                  layoutId={`${id}-selection`}
                  initial={false}
                  transition={transition}
                  className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-foreground/70"
                />
              )}
            </span>
            <span
              className={cn(
                "mt-2.5 block text-sm leading-none",
                selected ? "font-medium text-foreground" : "text-muted-foreground",
              )}
            >
              {option.label}
            </span>
          </motion.button>
        );
      })}
    </div>
  );
}

/** Static SVG artwork keeps every preview independent of the active site theme. */
function ThemePreview({ theme, id }: { theme: ThemeValue; id: string }) {
  const light = theme === "light";
  const system = theme === "system";
  const page = light ? "#fafafa" : "#080808";
  const ink = light ? "#111111" : "#f5f5f5";
  const line = light ? "#ececec" : "#262626";

  return (
    <svg aria-hidden="true" viewBox="0 0 124 80" fill="none" className="block w-full overflow-hidden rounded-xl">
      <defs>
        <linearGradient id={`${id}-backdrop`} x1="62" y1="0" x2="62" y2="80" gradientUnits="userSpaceOnUse">
          <stop stopColor={light ? "#252525" : "#080808"} />
          <stop offset="1" stopColor={light ? "#818181" : "#191919"} />
        </linearGradient>
        <linearGradient id={`${id}-beam`} x1="38" y1="0" x2="83" y2="80" gradientUnits="userSpaceOnUse">
          <stop stopColor="white" stopOpacity="0" />
          <stop offset="0.42" stopColor="white" stopOpacity="0.02" />
          <stop offset="0.5" stopColor="white" stopOpacity="0.24" />
          <stop offset="0.58" stopColor="white" stopOpacity="0.02" />
          <stop offset="1" stopColor="white" stopOpacity="0" />
        </linearGradient>
        <linearGradient id={`${id}-page`}>
          <stop offset="50%" stopColor={page} />
          <stop offset="50%" stopColor={system ? "#fafafa" : page} />
        </linearGradient>
        <linearGradient id={`${id}-lines`} x1="20" x2="104" gradientUnits="userSpaceOnUse">
          <stop offset="50%" stopColor={line} />
          <stop offset="50%" stopColor={system ? "#ececec" : line} />
        </linearGradient>
        <filter id={`${id}-grain`} x="0" y="0" width="100%" height="100%">
          <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch" />
          <feColorMatrix type="saturate" values="0" />
        </filter>
      </defs>
      <path fill={`url(#${id}-backdrop)`} d="M0 0h124v80H0z" />
      <path fill={`url(#${id}-beam)`} d="M0 0h124v80H0z" />
      <path fill="white" opacity={light ? 0.13 : 0.07} filter={`url(#${id}-grain)`} d="M0 0h124v80H0z" />
      <rect x="20" y="16" width="84" height="55" rx="4" fill="black" opacity="0.15" />
      <rect x="20" y="13" width="84" height="55" rx="4" fill={`url(#${id}-page)`} />
      <path d="M35 13v55" stroke={line} strokeOpacity="0.6" />
      <path d="M25 22v-4h4m-4 4 4-4" stroke={ink} strokeWidth="0.7" strokeLinecap="round" strokeLinejoin="round" />
      <g fill={`url(#${id}-lines)`}>
        <rect x="67" y="19" width="24" height="5" rx="2.5" />
        <rect x="47" y="29" width="27" height="3" rx="1" />
        <rect x="47" y="34" width="36" height="3" rx="1" />
        <rect x="47" y="39" width="20" height="3" rx="1" />
        <rect x="47" y="44" width="28" height="3" rx="1" />
        <rect x="47" y="59" width="43" height="4" rx="2" />
      </g>
      <circle cx="88" cy="61" r="1.8" fill={system ? "#111111" : ink} />
    </svg>
  );
}