"use client"; import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { useReducedMotion } from "motion/react"; import { useEffect, useState, type ComponentPropsWithoutRef } from "react"; import { ActionSwapIcon } from "@/components/motion/action-swap"; import { cn } from "@/lib/utils"; export type ThemeVariant = "rectangle" | "circle" | "circle-blur"; export type RectStart = | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center" | "bottom-up"; export interface ThemeToggleProps extends Omit, "children" | "onClick"> { /** Animation variant. Default: "rectangle". */ variant?: ThemeVariant; /** Origin direction for the reveal. Default: "bottom-up". */ start?: RectStart; iconClassName?: string; } const VT_STYLE_ID = "beui-theme-toggle-vt"; // Duration/easing is component-specific: View Transition API uses CSS, not // motion springs. 400ms + ease-out mirrors native OS mode-switch timing. const VT_CSS = ` html[data-beui-vt="rect"]::view-transition-old(root) { animation: none; mix-blend-mode: normal; } html[data-beui-vt="rect"]::view-transition-new(root) { mix-blend-mode: normal; animation: beui-rect-reveal 400ms ease-out; } html[data-beui-vt="circle"]::view-transition-old(root), html[data-beui-vt="circle-blur"]::view-transition-old(root) { animation: none; mix-blend-mode: normal; } html[data-beui-vt="circle"]::view-transition-new(root) { mix-blend-mode: normal; animation: beui-circle-reveal 500ms ease-out; } html[data-beui-vt="circle-blur"]::view-transition-new(root) { mix-blend-mode: normal; animation: beui-circle-blur-reveal 500ms ease-out; } @keyframes beui-rect-reveal { from { clip-path: var(--beui-vt-from, inset(100% 0 0 0)); } to { clip-path: inset(0 0 0 0); } } @keyframes beui-circle-reveal { from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); } to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); } } @keyframes beui-circle-blur-reveal { from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); filter: blur(8px); } to { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); filter: blur(0px); } } `; const RECT_FROM: Record = { "top-left": "inset(0 100% 100% 0)", "top-right": "inset(0 0 100% 100%)", "bottom-left": "inset(100% 100% 0 0)", "bottom-right":"inset(100% 0 0 100%)", center: "inset(50% 50% 50% 50%)", "bottom-up": "inset(100% 0 0 0)", }; const CIRCLE_ORIGIN: Record = { "top-left": "0% 0%", "top-right": "100% 0%", "bottom-left": "0% 100%", "bottom-right":"100% 100%", center: "50% 50%", "bottom-up": "50% 100%", }; export function useThemeToggle({ variant = "rectangle", start = "bottom-up", }: { variant?: ThemeVariant; start?: RectStart } = {}) { const { setTheme, resolvedTheme } = useTheme(); const reduce = useReducedMotion() ?? false; const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const isDark = mounted && resolvedTheme === "dark"; const toggle = () => { const next = isDark ? "light" : "dark"; if (reduce || !("startViewTransition" in document)) { setTheme(next); return; } const root = document.documentElement; if (variant === "rectangle") { root.style.setProperty("--beui-vt-from", RECT_FROM[start]); root.dataset.beuiVt = "rect"; } else { root.style.setProperty("--beui-vt-origin", CIRCLE_ORIGIN[start]); root.dataset.beuiVt = variant; } const vt = ( document as Document & { startViewTransition(cb: () => void): { finished: Promise }; } ).startViewTransition(() => setTheme(next)); vt.finished.finally(() => { delete root.dataset.beuiVt; }); }; return { isDark, mounted, toggle }; } export function ThemeToggle({ variant = "rectangle", start = "bottom-up", className, iconClassName, ...rest }: ThemeToggleProps) { const { isDark, mounted, toggle } = useThemeToggle({ variant, start }); useEffect(() => { if (document.getElementById(VT_STYLE_ID)) return; const el = document.createElement("style"); el.id = VT_STYLE_ID; el.textContent = VT_CSS; document.head.appendChild(el); }, []); return ( ); }