# Light/Dark Toggle

> Sun/moon light-dark theme toggle icon — the moon's crescent mask slides away as it shrinks into a sun, the icon rotates, and six rays pop in one by one.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/light-dark-toggle).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/light-dark-toggle
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/light-dark-toggle-light-cc43b5544b59df22.png
- Registry item: https://uicapsule.com/r/light-dark-toggle.json
- Categories: controls, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/light-dark-toggle.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: `motion`

## Files

### `/light-dark-toggle.tsx`

```tsx
"use client";

import { useId } from "react";
import { motion } from "motion/react";

/**
 * The six sun rays, in the order they pop in when switching to light.
 * Positions are the vertices of a hexagon inscribed at r=8 around (9, 9);
 * `delay` is written out literally rather than derived from the index so the
 * stagger ramp stays free of floating-point drift.
 */
const SUN_RAYS = [
  { cx: 17, cy: 9, delay: 0 },
  { cx: 13, cy: 15.928203230275509, delay: 0.05 },
  { cx: 5.000000000000002, cy: 15.92820323027551, delay: 0.1 },
  { cx: 1, cy: 9.000000000000002, delay: 0.15 },
  { cx: 4.9999999999999964, cy: 2.071796769724492, delay: 0.2 },
  { cx: 13, cy: 2.0717967697244912, delay: 0.25 },
] as const;

const RAY_RADIUS = 1.5;

interface LightDarkToggleProps {
  isLight: boolean;
}

export const LightDarkToggle = ({ isLight }: LightDarkToggleProps) => {
  // Scoped so several toggles can coexist on one page without their masks colliding.
  const maskId = `moon-mask-${useId().replaceAll(/[^a-zA-Z0-9]/gu, "")}`;

  return (
    <svg
      viewBox="0 0 18 18"
      style={{ transform: isLight ? "rotate(90deg)" : "rotate(40deg)" }}
      className="size-6 overflow-visible transition-transform duration-500"
    >
      <mask id={maskId}>
        <rect x="0" y="0" width="18" height="18" fill="#FFF" />
        <motion.circle animate={{ cx: isLight ? 25 : 10 }} cy="2" r="8" fill="black" />
      </mask>
      <motion.circle
        cx="9"
        cy="9"
        fill="currentColor"
        mask={`url(#${maskId})`}
        animate={{ r: isLight ? 5 : 8 }}
      />
      <g>
        {SUN_RAYS.map((ray) => (
          <motion.circle
            key={`${ray.cx}-${ray.cy}`}
            cx={ray.cx}
            cy={ray.cy}
            r={RAY_RADIUS}
            fill="currentColor"
            animate={{ scale: isLight ? 1 : 0 }}
            transition={{ delay: isLight ? ray.delay : 0 }}
          />
        ))}
      </g>
    </svg>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { useState } from "react";

import { LightDarkToggle } from "./light-dark-toggle";

const Preview = () => {
  const [isLight, setIsLight] = useState(true);

  return (
    <div
      className={`main flex h-dvh items-center justify-center ${isLight ? "bg-stone-300" : "bg-stone-900"}`}
    >
      <button
        type="button"
        aria-label={isLight ? "Switch to dark mode" : "Switch to light mode"}
        className={`grid size-12 place-items-center rounded-xl bg-gradient-to-t shadow-lg ${isLight ? "from-[#f8fafc] to-[#f1f5f9] text-stone-950" : "from-[#020617] to-[#0F172A] text-stone-50"}`}
        onClick={() => setIsLight(!isLight)}
      >
        <LightDarkToggle isLight={isLight} />
      </button>
    </div>
  );
};

export default Preview;
```
