# Theme Toggle Effect

Animated transitions when switching between light and dark themes.

```tsx
"use client"

import { MoonIcon, SunMediumIcon } from "lucide-react"
import { useTheme } from "next-themes"

import { useClickSound } from "@/hooks/soundcn/use-click-sound"
import { Button } from "@/components/ui/button"

/** @internal */
import { ThemeToggleEffectSelector } from "./theme-toggle-effect-selector"

export default function ThemeToggleEffectDemo() {
  const { resolvedTheme, setTheme } = useTheme()

  const [click] = useClickSound()

  const switchTheme = () => {
    setTheme(resolvedTheme === "dark" ? "light" : "dark")
  }

  const handleThemeToggleClick = () => {
    click()
    if (!document.startViewTransition) switchTheme()
    else document.startViewTransition(switchTheme)
  }

  return (
    <div className="flex gap-2">
      <ThemeToggleEffectSelector />

      <Button
        variant="outline"
        size="icon"
        aria-label="Theme Toggle"
        onClick={handleThemeToggleClick}
      >
        <MoonIcon className="hidden [html.dark_&]:block" />
        <SunMediumIcon className="hidden [html.light_&]:block" />
      </Button>
    </div>
  )
}

```

<Testimonial authorAvatar="https://unavatar.io/x/orcdev" authorName="OrcDev" authorTagline="Creator of 8bitcn.com" url="https://x.com/orcdev/status/2042294526589518083" quote="oh I love this one, have to implement it on orcdev.com" date="2026-04-10" />

## Browser Compatibility

This component uses the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API). Check the latest [browser compatibility on MDN](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API#browser_compatibility) before using in production.

## Installation

<CodeTabs>
  <TabsListInstallType />

  <TabsContent value="cli">
    ### Triangle

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-triangle
    ```

    ### Triangle Blur

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-triangle-blur
    ```

    ### Circle

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-circle
    ```

    ### Circle Blur

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-circle-blur
    ```

    ### Circle Blur (Top Left)

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-circle-blur-top-left
    ```

    ### Polygon

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-polygon
    ```

    ### Polygon Gradient

    ```bash
    npx shadcn@latest add @ncdai/theme-toggle-effect-polygon-gradient
    ```
  </TabsContent>

  <TabsContent value="manual">
    <Steps>
      <Step>Add the easing variable to your global CSS</Step>

      ```css
      :root {
        --expo-out: linear(
          0 0%,
          0.1684 2.66%,
          0.3165 5.49%,
          0.446 8.52%,
          0.5581 11.78%,
          0.6535 15.29%,
          0.7341 19.11%,
          0.8011 23.3%,
          0.8557 27.93%,
          0.8962 32.68%,
          0.9283 38.01%,
          0.9529 44.08%,
          0.9711 51.14%,
          0.9833 59.06%,
          0.9915 68.74%,
          1 100%
        );
      }
      ```

      <Step>Add the effect CSS to your global CSS</Step>

      Choose one effect and copy its CSS into your `globals.css`

      **Triangle**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: none;
          animation-fill-mode: both;
          z-index: -1;
        }

        ::view-transition-new(root) {
          mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path d="m20 0 20 35H0z" fill="white"/></svg>')
            center / 0 no-repeat;
          animation: scale 0.7s;
          animation-fill-mode: both;
        }

        @keyframes scale {
          to {
            mask-size: 300vmax;
          }
        }
      }
      ```

      **Triangle Blur**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: none;
          animation-fill-mode: both;
          z-index: -1;
        }

        ::view-transition-new(root) {
          mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path d="m20 0 20 35H0z" fill="white" filter="url(%23blur)"/><defs><filter id="blur"><feGaussianBlur stdDeviation="1"/></filter></defs></svg>')
            center / 0 no-repeat;
          animation: scale 0.7s;
          animation-fill-mode: both;
        }

        @keyframes scale {
          to {
            mask-size: 300vmax;
          }
        }
      }
      ```

      **Circle**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: none;
          animation-fill-mode: both;
          z-index: -1;
        }

        ::view-transition-new(root) {
          mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="white"/></svg>')
            center / 0 no-repeat;
          animation: scale 1s;
          animation-fill-mode: both;
        }

        @keyframes scale {
          to {
            mask-size: 200vmax;
          }
        }
      }
      ```

      **Circle Blur**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: none;
          animation-fill-mode: both;
          z-index: -1;
        }

        ::view-transition-new(root) {
          mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>')
            center / 0 no-repeat;
          animation: scale 1s;
          animation-fill-mode: both;
        }

        .dark::view-transition-new(root) {
          animation: scale 1s;
          animation-fill-mode: both;
        }

        @keyframes scale {
          to {
            mask-size: 200vmax;
          }
        }
      }
      ```

      **Circle Blur (Top Left)**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-new(root) {
          mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="0" cy="0" r="18" fill="white" filter="url(%23blur)"/></svg>')
            top left / 0 no-repeat;
          mask-origin: content-box;
          animation: scale 1s;
          animation-fill-mode: both;
          transform-origin: top left;
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: scale 1s;
          animation-fill-mode: both;
          transform-origin: top left;
          z-index: -1;
        }

        @keyframes scale {
          to {
            mask-size: 350vmax;
          }
        }
      }
      ```

      **Polygon**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-duration: 0.7s;
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-new(root) {
          animation-name: reveal-light;
          animation-fill-mode: both;
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: none;
          animation-fill-mode: both;
          z-index: -1;
        }

        .dark::view-transition-new(root) {
          animation-name: reveal-dark;
          animation-fill-mode: both;
        }

        @keyframes reveal-dark {
          from {
            clip-path: polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%);
          }
          to {
            clip-path: polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%);
          }
        }

        @keyframes reveal-light {
          from {
            clip-path: polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%);
          }
          to {
            clip-path: polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%);
          }
        }
      }
      ```

      **Polygon Gradient**

      ```css
      @layer base {
        ::view-transition-group(root) {
          animation-timing-function: var(--expo-out);
        }

        ::view-transition-new(root) {
          mask: url('data:image/svg+xml,<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 0H40L0 40V0Z" fill="url(%23paint0_linear_16_14)"/><defs><linearGradient id="paint0_linear_16_14" x1="0" y1="0" x2="20.5" y2="20.5" gradientUnits="userSpaceOnUse"><stop stop-color="current"/><stop offset="0.84506" stop-color="current" stop-opacity="0.99"/><stop offset="0.9506" stop-color="current" stop-opacity="0"/><stop offset="1" stop-color="current" stop-opacity="0"/></linearGradient></defs></svg>')
            top left / 0 no-repeat;
          mask-origin: top left;
          animation: scale 1.5s;
          animation-fill-mode: both;
        }

        ::view-transition-old(root),
        .dark::view-transition-old(root) {
          animation: scale 1.5s;
          animation-fill-mode: both;
          z-index: -1;
          transform-origin: top left;
        }

        @keyframes scale {
          to {
            mask-size: 200vmax;
          }
        }
      }
      ```

      <Step>Use startViewTransition in your theme toggle</Step>
    </Steps>
  </TabsContent>
</CodeTabs>

## Usage

Wrap your theme setter with `document.startViewTransition` to trigger the effect:

```tsx
const { setTheme } = useTheme()

function toggleTheme(theme: string) {
  if (!document.startViewTransition) {
    setTheme(theme)
    return
  }

  document.startViewTransition(() => setTheme(theme))
}
```

## Credits

* [@rds\_agi](https://theme-toggle.rdsx.dev)

## References

* [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)

<DocSponsors />


Last updated on April 10, 2026