For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Theme Toggle

A light and dark theme control built on Base UI and next-themes.

The preview uses local state. For an application, wrap your app in a next-themes provider and use ThemeToggle without value.

Variants

variant="icon" renders a single outline icon button with a tooltip and a D key hint. It flips the theme with the same circular reveal as the other variants. The D shortcut itself is wired by the host app.

Installation

$ pnpm dlx shadcn@latest add @cobalt/theme-toggle

Source

"use client";

import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { RiMoonLine, RiSunLine } from "@remixicon/react";
import { cn } from "cn";
import { useTheme } from "next-themes";
import * as React from "react";
import { flushSync } from "react-dom";

import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { Switch } from "@/components/ui/switch";

type ThemeMode = "light" | "dark";
interface Origin {
  x: number;
  y: number;
}
type TransitionDocument = Document & {
  startViewTransition?: (update: () => void) => {
    ready: Promise<void>;
    finished: Promise<void>;
  };
};

let transitionRunning = false;

export interface ThemeTransitionOptions {
  /** The control the reveal grows from, when there is no `point`. */
  element?: HTMLElement | null;
  /** Viewport coordinates of the click. Without one, the element or the viewport centre is used. */
  point?: Origin | null;
  duration?: number;
}

/**
 * Runs `update` inside the circular view-transition reveal. Without the View
 * Transitions API, or under reduced motion, `update` runs at once.
 */
export function runThemeTransition(
  update: () => void,
  { element, point, duration = 820 }: ThemeTransitionOptions = {}
) {
  if (transitionRunning) {
    return;
  }
  const doc = document as TransitionDocument;
  if (
    !doc.startViewTransition ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  ) {
    update();
    return;
  }
  const rect = element?.getBoundingClientRect();
  const x = Math.min(
    Math.max(
      point?.x ?? (rect ? rect.left + rect.width / 2 : innerWidth / 2),
      0
    ),
    innerWidth
  );
  const y = Math.min(
    Math.max(
      point?.y ?? (rect ? rect.top + rect.height / 2 : innerHeight / 2),
      0
    ),
    innerHeight
  );
  const radius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y)
  );
  const length = radius * 2.5;
  const svg =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2" /></filter></defs><circle cx="50" cy="50" r="42" fill="white" filter="url(#blur)" /></svg>';
  const style = document.createElement("style");
  // Named elements (a sticky header, a footer) are lifted out of the root
  // snapshot and would cross-fade on their own timing instead of being
  // revealed by the mask. Unnamed for the length of this transition, they
  // change together with the page.
  style.textContent = `html *{view-transition-name:none!important}::view-transition-old(root){animation:none;mix-blend-mode:normal}::view-transition-new(root){mix-blend-mode:normal;-webkit-mask-image:url("data:image/svg+xml,${encodeURIComponent(svg)}");mask-image:url("data:image/svg+xml,${encodeURIComponent(svg)}");-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;animation:theme-toggle-reveal ${duration}ms cubic-bezier(0.16,1,0.3,1) both;transform-origin:${x}px ${y}px;will-change:mask-size,mask-position}@keyframes theme-toggle-reveal{from{mask-position:${x}px ${y}px;mask-size:0 0;-webkit-mask-position:${x}px ${y}px;-webkit-mask-size:0 0}to{mask-position:${x - length / 2}px ${y - length / 2}px;mask-size:${length}px ${length}px;-webkit-mask-position:${x - length / 2}px ${y - length / 2}px;-webkit-mask-size:${length}px ${length}px}}`;
  document.head.append(style);
  transitionRunning = true;
  let applied = false;
  try {
    const transition = doc.startViewTransition(() => {
      flushSync(() => {
        update();
        applied = true;
      });
    });
    void transition.ready
      .then(() => transition.finished)
      .catch(() => {
        if (!applied) {
          update();
        }
      })
      .finally(() => {
        style.remove();
        transitionRunning = false;
      });
  } catch {
    if (!applied) {
      update();
    }
    style.remove();
    transitionRunning = false;
  }
}

export interface ThemeToggleProps {
  variant?:
    | "icon"
    | "sidebar"
    | "segmented"
    | "sidebar-segmented"
    | "glass-segmented";
  collapsed?: boolean;
  className?: string;
  size?: "sm" | "default";
  transitionDuration?: number;
  value?: ThemeMode;
  onValueChange?: (value: ThemeMode) => void;
  /**
   * With `value`, still draw the thumb and icons from the `.dark` class on
   * `html`, which is set before first paint. `value` then only drives
   * `aria-pressed` and click handling.
   */
  documentTheme?: boolean;
}

function ThemeToggle({
  variant = "sidebar",
  collapsed = false,
  className,
  size = "default",
  transitionDuration = 820,
  value,
  onValueChange,
  documentTheme = false,
}: ThemeToggleProps) {
  const id = React.useId();
  const { resolvedTheme, setTheme } = useTheme();
  // next-themes only knows the stored theme on the client, so the first client
  // render must match the server (light). `settled` keeps the thumb from
  // sliding to the stored theme on load.
  const [mounted, setMounted] = React.useState(false);
  const [settled, setSettled] = React.useState(false);
  React.useEffect(() => {
    setMounted(true);
    let second = 0;
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(() => setSettled(true));
    });
    return () => {
      cancelAnimationFrame(first);
      cancelAnimationFrame(second);
    };
  }, []);
  const theme =
    value ?? (mounted && resolvedTheme === "dark" ? "dark" : "light");
  const dark = theme === "dark";
  // Uncontrolled toggles follow the html class, so the first paint is right.
  const fromClass = value === undefined || documentTheme;
  const origin = React.useRef<Origin | null>(null);
  const control = React.useRef<HTMLElement | null>(null);

  function change(
    next: ThemeMode,
    element: HTMLElement | null,
    point: Origin | null
  ) {
    if (next === theme) {
      return;
    }
    const update = () => {
      if (value === undefined) {
        setTheme(next);
      }
      onValueChange?.(next);
    };
    // Controlled previews do not animate the document or change the host theme.
    if (value !== undefined) {
      update();
      return;
    }
    runThemeTransition(update, {
      element,
      point,
      duration: transitionDuration,
    });
  }

  if (variant === "icon") {
    return (
      <Button
        data-slot="theme-toggle"
        variant="outline"
        size="icon"
        aria-label="Toggle theme"
        className={className}
        onClick={(event) =>
          change(
            dark ? "light" : "dark",
            event.currentTarget,
            event.clientX === 0 && event.clientY === 0
              ? null
              : { x: event.clientX, y: event.clientY }
          )
        }
        tooltip={
          <>
            Toggle Mode
            <Kbd>D</Kbd>
          </>
        }
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          className="size-4"
          aria-hidden="true"
        >
          <path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
          <path d="M12 3l0 18" />
          <path d="M12 9l4.65 -4.65" />
          <path d="M12 14.3l7.37 -7.37" />
          <path d="M12 19.6l8.85 -8.85" />
        </svg>
      </Button>
    );
  }
  if (variant !== "sidebar") {
    const glass = variant === "glass-segmented";
    const small = size === "sm" && !glass;
    const sidebar = variant === "sidebar-segmented";
    return (
      <div
        data-slot="theme-toggle"
        role="group"
        aria-label="Theme"
        className={cn(
          "relative inline-flex w-fit items-center gap-1 rounded-full p-1",
          glass
            ? "bg-transparent p-0.5 sm:p-1"
            : sidebar
              ? "bg-[var(--surface-strong,var(--muted))]"
              : "bg-[var(--surface-subtle,var(--muted))]",
          small && "gap-0.5 p-0.5",
          className
        )}
      >
        <span
          aria-hidden="true"
          className={cn(
            "pointer-events-none absolute top-1 start-1 size-8 rounded-full shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] transition-transform duration-200 ease-[ease] motion-reduce:transition-none",
            glass
              ? "top-0.5 start-0.5 size-7 bg-[var(--theme-toggle-glass-selected,var(--background))] sm:top-1 sm:start-1 sm:size-8"
              : sidebar
                ? "bg-[var(--theme-toggle-sidebar-selected,var(--popover))]"
                : "bg-[var(--surface-raised,var(--popover))]",
            small && "top-0.5 start-0.5 size-6",
            !settled && "transition-none",
            fromClass
              ? small
                ? "in-[.dark]:translate-x-6.5 rtl:in-[.dark]:-translate-x-6.5"
                : glass
                  ? "in-[.dark]:translate-x-8 rtl:in-[.dark]:-translate-x-8 sm:in-[.dark]:translate-x-9 sm:rtl:in-[.dark]:-translate-x-9"
                  : "in-[.dark]:translate-x-9 rtl:in-[.dark]:-translate-x-9"
              : dark &&
                  (small
                    ? "translate-x-6.5 rtl:-translate-x-6.5"
                    : glass
                      ? "translate-x-8 rtl:-translate-x-8 sm:translate-x-9 sm:rtl:-translate-x-9"
                      : "translate-x-9 rtl:-translate-x-9")
          )}
        />
        {(["light", "dark"] as const).map((mode) => {
          const selected = theme === mode;
          const Icon = mode === "light" ? RiSunLine : RiMoonLine;
          return (
            <ButtonPrimitive
              key={mode}
              type="button"
              aria-label={`Use ${mode} mode`}
              aria-pressed={selected}
              title={`${mode === "light" ? "Light" : "Dark"} mode`}
              onClick={(event) =>
                change(
                  mode,
                  event.currentTarget,
                  event.clientX === 0 && event.clientY === 0
                    ? null
                    : { x: event.clientX, y: event.clientY }
                )
              }
              className={cn(
                "relative z-10 grid size-8 cursor-pointer place-items-center rounded-full text-[color:var(--foreground-secondary,var(--muted-foreground))] outline-none hover:text-foreground hover:transition-colors hover:duration-150 hover:ease-[ease] focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
                fromClass
                  ? mode === "light"
                    ? "text-foreground in-[.dark]:text-[color:var(--foreground-secondary,var(--muted-foreground))]"
                    : "in-[.dark]:text-foreground"
                  : selected && "text-foreground",
                glass && "size-7 sm:size-8",
                small && "size-6"
              )}
            >
              <Icon
                aria-hidden="true"
                className={small ? "size-3.5" : "size-4"}
              />
            </ButtonPrimitive>
          );
        })}
      </div>
    );
  }
  if (collapsed) {
    const Icon = dark ? RiSunLine : RiMoonLine;
    return (
      <ButtonPrimitive
        data-slot="theme-toggle"
        type="button"
        aria-label={dark ? "Use light mode" : "Use dark mode"}
        aria-pressed={dark}
        title={dark ? "Light mode" : "Dark mode"}
        onClick={(event) =>
          change(
            dark ? "light" : "dark",
            event.currentTarget,
            event.clientX === 0 && event.clientY === 0
              ? null
              : { x: event.clientX, y: event.clientY }
          )
        }
        className={cn(
          "flex size-9 cursor-pointer items-center justify-center rounded-[10px] text-[color:var(--foreground-secondary,var(--muted-foreground))] outline-none transition-colors duration-150 ease-[ease] hover:bg-[var(--surface-strong,var(--muted))] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
          className
        )}
      >
        {fromClass ? (
          <>
            <RiMoonLine
              className="size-5 in-[.dark]:hidden"
              aria-hidden="true"
            />
            <RiSunLine
              className="hidden size-5 in-[.dark]:block"
              aria-hidden="true"
            />
          </>
        ) : (
          <Icon className="size-5" aria-hidden="true" />
        )}
      </ButtonPrimitive>
    );
  }
  return (
    <label
      htmlFor={id}
      data-slot="theme-toggle"
      className={cn(
        "flex w-full cursor-pointer items-center justify-between rounded-[10px] p-2 transition-colors duration-150 ease-[ease] hover:bg-[var(--surface-strong,var(--muted))] has-focus-visible:ring-2 has-focus-visible:ring-inset has-focus-visible:ring-ring motion-reduce:transition-none",
        className
      )}
    >
      <span className="flex min-w-0 items-center gap-2">
        <RiMoonLine
          className="size-5 shrink-0 text-[color:var(--foreground-secondary,var(--muted-foreground))]"
          aria-hidden="true"
        />
        <span className="text-sm leading-5 font-medium text-[color:var(--foreground-secondary,var(--muted-foreground))]">
          Dark mode
        </span>
      </span>
      <Switch
        id={id}
        ref={control}
        size="sm"
        checked={dark}
        aria-label="Dark mode"
        onPointerDown={(event) => {
          origin.current = { x: event.clientX, y: event.clientY };
        }}
        onCheckedChange={(checked) => {
          change(checked ? "dark" : "light", control.current, origin.current);
          origin.current = null;
        }}
      />
    </label>
  );
}

export { ThemeToggle };
export type { Origin as ThemeTransitionPoint };