# Status Theme Switcher

> Agnostic light/dark/system theme switcher. Caller owns the value and onValueChange wiring (next-themes, form state, etc.)

A shadcn/ui block from the [@openstatus](https://openstatus.dev/registry) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/openstatus/status-theme-switcher).

- Page, with a live preview: https://shadcn.exchange/r/openstatus/status-theme-switcher
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/openstatus/status-theme-switcher-light-de6a721fd8cc6090.png
- Registry item: https://openstatus.dev/r/status-theme-switcher.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://openstatus.dev/r/status-theme-switcher.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `https://openstatus.dev/r/status-i18n.json`, `https://openstatus.dev/r/status-types.json`, `button`, `dropdown-menu`, `skeleton`

## Files

### `src/components/blocks/status-theme-switcher.tsx`

```tsx
"use client";

import { useStatusBlocksLabels } from "@/components/blocks/status-i18n";
import {
  THEME_VALUES,
  type ThemeValue,
} from "@/components/blocks/status.types";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { Laptop, Moon, Sun } from "lucide-react";

export type { ThemeValue };

function ThemeIcon({
  value,
  className,
}: {
  value: ThemeValue;
  className?: string;
}) {
  const Icon = value === "light" ? Sun : value === "dark" ? Moon : Laptop;
  return <Icon className={cn("h-4 w-4", className)} />;
}

/**
 * StatusThemeSwitcher — agnostic light/dark/system switcher.
 *
 * Pure presentation: caller owns the value and onValueChange wiring (next-themes,
 * form state, etc.). The block does no hydration handling — render a Skeleton
 * yourself before the active theme is known.
 */
export function StatusThemeSwitcher({
  value,
  onValueChange,
  className,
  ...props
}: Omit<
  React.ComponentProps<typeof DropdownMenuTrigger>,
  "value" | "onValueChange"
> & {
  value: ThemeValue;
  onValueChange: (value: ThemeValue) => void;
}) {
  const labels = useStatusBlocksLabels();
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild className={className} {...props}>
        <Button data-slot="status-theme-switcher" variant="ghost" size="icon">
          <ThemeIcon value={value} />
          <span className="sr-only">{labels.ariaToggleTheme}</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" alignOffset={-4}>
        {THEME_VALUES.map((v) => {
          const isActive = v === value;
          return (
            <DropdownMenuItem
              data-slot="status-theme-switcher-item"
              key={v}
              onClick={() => onValueChange(v)}
            >
              <span>{labels.themeNames[v]}</span>
              <span className="ml-auto">
                <ThemeIcon
                  value={v}
                  className={
                    isActive ? "text-foreground" : "text-muted-foreground"
                  }
                />
              </span>
            </DropdownMenuItem>
          );
        })}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

/**
 * StatusThemeSwitcherSkeleton — same footprint as the trigger button.
 * Render while the active theme isn't known yet (e.g. before next-themes
 * hydrates) so the surrounding layout doesn't shift.
 */
export function StatusThemeSwitcherSkeleton({
  className,
  ...props
}: React.ComponentProps<typeof Skeleton>) {
  return (
    <Skeleton
      data-slot="status-theme-switcher-skeleton"
      className={cn("size-9", className)}
      {...props}
    />
  );
}
```
