Skip to content
goodtabs UI
BrowseBrowse components

Theme toggle

Switches the html dark class and remembers the choice, with no theme library.

npx shadcn@latest add https://ui.goodtabs.co/r/theme-toggle.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/theme-toggle

The ghost icon button. Click it and this page switches theme.

Outline

The outline variant at the default and icon-sm sizes.

In a header row

Sits with the actions on the right of a 56px header.

Usage

import { ThemeToggle } from "@/components/ui/theme-toggle"

<ThemeToggle />
<ThemeToggle variant="outline" size="icon-sm" />

Props

PropTypeDefaultDescription
variantButton variant"ghost"Any shadcn button variant. Ghost for headers, outline on its own.
sizeButton size"icon"Any icon size: icon is 32px, icon-sm 28px, icon-xs 24px.

Source

theme-toggle/theme-toggle.tsx
"use client";

import * as React from "react";
import { MoonIcon, SunIcon, SunMoonIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type Theme = "light" | "dark";

const STORAGE_KEY = "theme";

function subscribe(onChange: () => void) {
  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
  return () => observer.disconnect();
}

function readTheme(): Theme {
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
}

/** The theme is null until mounted, so server and client markup match. */
function useHtmlTheme(): Theme | null {
  return React.useSyncExternalStore(subscribe, readTheme, () => null);
}

function applyTheme(next: Theme) {
  const root = document.documentElement;
  root.classList.remove("light", "dark");
  root.classList.add(next);
  root.style.colorScheme = next;
  try {
    window.localStorage.setItem(STORAGE_KEY, next);
  } catch {
    // Storage can be blocked (private mode, quota). The class still switches for this visit.
  }
}

type ThemeToggleProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "children" | "aria-label">;

/**
 * Switches the html dark class and remembers the choice in localStorage under "theme".
 * No theme library: it reads the class after mount and watches it for outside changes.
 */
export function ThemeToggle({ variant = "ghost", size = "icon", className, ...props }: ThemeToggleProps) {
  const theme = useHtmlTheme();
  const Icon = theme === null ? SunMoonIcon : theme === "dark" ? SunIcon : MoonIcon;
  const label = theme === null ? "Switch theme" : theme === "dark" ? "Switch to light theme" : "Switch to dark theme";

  return (
    <Button
      type="button"
      variant={variant}
      size={size}
      aria-label={label}
      data-theme={theme ?? undefined}
      onClick={() => applyTheme((theme ?? readTheme()) === "dark" ? "light" : "dark")}
      className={cn("text-muted-foreground transition-colors", className)}
      {...props}
    >
      <Icon aria-hidden="true" />
    </Button>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/theme-toggle/prompt.md, or the Copy AI prompt button above.

When to use

  • In the header or footer of a site that has a light and a dark theme
  • In a settings row or command menu as the one place to switch
  • When you do not want a theme library for a single toggle

When not to use

  • For a third system option; this is a two-state switch
  • When the theme is set per user on the server; wire the choice to your own store instead
  • If the site has no dark theme yet; add the dark tokens first

Agent notes

  • It toggles the dark class on html and writes light or dark to localStorage under the key theme; add a small inline script in the head that reads that key before paint to avoid a flash
  • It renders a neutral icon and the label Switch theme until mounted, then reads the html class, so there is no hydration mismatch
  • On goodtabs.co next-themes reads the same key and class, so both stay in sync; a next-themes toggle on the same page keeps working
  • It watches the html class, so a theme set from anywhere else on the page updates the icon and label