Back to buttons
"use client";

/**
 * @author: @dorianbaffier
 * @description: Switch Button
 * @version: 2.0.0
 * @date: 2026-10-16
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 *
 * Works on its own: it toggles the `dark` class on <html>. If a theme provider
 * owns the theme (next-themes or your own), pass `theme` and `onThemeChange`.
 *
 * The new theme spreads from the button as a circle (View Transitions API).
 * Without that API, or with reduced motion, the theme switches instantly.
 */

import { useEffect, useId, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { cn } from "@/lib/utils";

type Theme = "light" | "dark";

interface SwitchButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  size?: "sm" | "default" | "lg";
  showLabel?: boolean;
  /**
   * The applied theme, when a theme provider owns it (for next-themes, its
   * `resolvedTheme`). Pair it with `onThemeChange`. Without it the button
   * toggles the `dark` class on <html> itself.
   */
  theme?: Theme;
  /** Called with the theme to switch to. */
  onThemeChange?: (theme: Theme) => void;
  /** Spread the new theme from the button as a circle. */
  reveal?: boolean;
}

const SIZES = {
  sm: { button: "h-8 gap-1.5 px-2.5 text-[13px]", square: "w-8", icon: 14 },
  default: { button: "h-9 gap-2 px-3 text-sm", square: "w-9", icon: 16 },
  lg: { button: "h-10 gap-2 px-3.5 text-[15px]", square: "w-10", icon: 18 },
};

const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const EASE_IN_OUT = "cubic-bezier(0.65, 0, 0.35, 1)";
const EASE_REVEAL = "cubic-bezier(0.4, 0, 0.2, 1)";
const REVEAL_ATTR = "data-theme-reveal";
const REVEAL_STYLE_ID = "kokonut-theme-reveal";
const REVEAL_CSS = `html[${REVEAL_ATTR}]::view-transition-old(root),html[${REVEAL_ATTR}]::view-transition-new(root){animation:none;mix-blend-mode:normal}`;

const RAY_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315];
const RAYS = RAY_ANGLES.map((deg) => {
  const rad = (deg * Math.PI) / 180;
  const at = (r: number) => ({
    x: Number((12 + r * Math.cos(rad)).toFixed(2)),
    y: Number((12 + r * Math.sin(rad)).toFixed(2)),
  });
  return { deg, from: at(7.5), to: at(10) };
});

// Each part's light look is its base class and its dark look a `dark:` class,
// so the server render (theme unknown) matches the client. Clicking animates
// from the old computed values to the new ones with WAAPI, which keeps
// working when a provider disables CSS transitions while it swaps themes.
const ANIMATED_PROPS = ["opacity", "scale", "translate", "filter"] as const;

type Snapshot = Map<Element, Keyframe>;

function snapshot(elements: Element[]): Snapshot {
  const map: Snapshot = new Map();
  for (const el of elements) {
    const style = getComputedStyle(el);
    const frame: Keyframe = { offset: 0 };
    for (const prop of ANIMATED_PROPS) {
      frame[prop] = style.getPropertyValue(prop);
    }
    map.set(el, frame);
    for (const animation of el.getAnimations()) {
      animation.cancel();
    }
  }
  return map;
}

function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function ensureRevealStyle() {
  if (document.getElementById(REVEAL_STYLE_ID)) {
    return;
  }
  const style = document.createElement("style");
  style.id = REVEAL_STYLE_ID;
  style.textContent = REVEAL_CSS;
  document.head.appendChild(style);
}

function timing(el: Element, toDark: boolean): KeyframeAnimationOptions {
  const { part, index = "0" } = (el as HTMLElement | SVGElement).dataset;
  const i = Number(index);
  switch (part) {
    case "ray":
      // Rays retract one after another; on the way back they wait for the
      // bite to clear first.
      return {
        delay: toDark ? i * 18 : 140 + i * 22,
        duration: 260,
        easing: EASE_OUT,
      };
    case "bite":
      return { delay: toDark ? 60 : 0, duration: 380, easing: EASE_IN_OUT };
    case "disc":
      return { delay: toDark ? 40 : 0, duration: 380, easing: EASE_OUT };
    default:
      return { duration: 240, easing: EASE_OUT };
  }
}

function morph(from: Snapshot, toDark: boolean) {
  if (prefersReducedMotion()) {
    return;
  }
  for (const [el, frame] of from) {
    el.animate([frame, {}], { ...timing(el, toDark), fill: "backwards" });
  }
}

let revealRunning = false;

function revealFrom(button: HTMLElement, apply: () => void) {
  const doc = document as Document & { activeViewTransition?: unknown };
  if (
    typeof doc.startViewTransition !== "function" ||
    prefersReducedMotion() ||
    revealRunning ||
    doc.activeViewTransition
  ) {
    apply();
    return;
  }

  const rect = button.getBoundingClientRect();
  const x = rect.left + rect.width / 2;
  const y = rect.top + rect.height / 2;
  const radius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
  const root = document.documentElement;

  ensureRevealStyle();
  root.setAttribute(REVEAL_ATTR, "");
  revealRunning = true;

  let transition: ViewTransition;
  try {
    transition = doc.startViewTransition(() => flushSync(apply));
  } catch {
    root.removeAttribute(REVEAL_ATTR);
    revealRunning = false;
    apply();
    return;
  }

  transition.ready
    .then(() => {
      root.animate(
        {
          clipPath: [
            `circle(0px at ${x}px ${y}px)`,
            `circle(${radius}px at ${x}px ${y}px)`,
          ],
        },
        {
          duration: 500,
          easing: EASE_REVEAL,
          pseudoElement: "::view-transition-new(root)",
        }
      );
    })
    .catch(() => {
      // Skipped or aborted (e.g. a page navigation started a transition).
    });

  transition.finished
    .catch(() => {
      // The theme is already applied by the time a transition can fail.
    })
    .finally(() => {
      root.removeAttribute(REVEAL_ATTR);
      revealRunning = false;
    });
}

export default function SwitchButton({
  className,
  size = "default",
  showLabel = true,
  theme,
  onThemeChange,
  reveal = true,
  onClick,
  ...props
}: SwitchButtonProps) {
  const maskId = `${useId()}-moon`;
  const iconRef = useRef<SVGSVGElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const [observed, setObserved] = useState<Theme | null>(null);

  // Only for aria-pressed: read the applied theme after mount, so the first
  // client render matches the server.
  useEffect(() => {
    const root = document.documentElement;
    const read = () =>
      setObserved(root.classList.contains("dark") ? "dark" : "light");
    read();
    const observer = new MutationObserver(read);
    observer.observe(root, { attributes: true, attributeFilter: ["class"] });
    return () => observer.disconnect();
  }, []);

  const current = observed && (theme ?? observed);
  const sizing = SIZES[size];

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    onClick?.(e);
    if (e.defaultPrevented) {
      return;
    }
    const root = document.documentElement;
    const from = theme ?? (root.classList.contains("dark") ? "dark" : "light");
    const next: Theme = from === "dark" ? "light" : "dark";
    const button = e.currentTarget;

    const apply = () => {
      const parts = [
        ...(iconRef.current?.querySelectorAll("[data-part]") ?? []),
        ...(labelRef.current?.querySelectorAll("[data-part]") ?? []),
      ];
      const before = snapshot(parts);
      // With a provider the class is also set here, because next-themes
      // applies it in an effect: the reveal's snapshot needs it now. The
      // provider then sets the same value.
      root.classList.toggle("dark", next === "dark");
      root.style.colorScheme = next;
      onThemeChange?.(next);
      morph(before, next === "dark");
    };

    if (reveal) {
      revealFrom(button, apply);
    } else {
      apply();
    }
  };

  return (
    <button
      aria-label="Dark theme"
      aria-pressed={current ? current === "dark" : undefined}
      className={cn(
        "group/switch relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center rounded-lg font-medium",
        "border border-zinc-200 bg-white text-zinc-900 shadow-xs",
        "dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100",
        "hover:border-zinc-300 hover:bg-zinc-50 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/70",
        "active:bg-zinc-100 dark:active:bg-zinc-800",
        "transition-[background-color,border-color] duration-150 ease-out",
        "outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-zinc-500 dark:focus-visible:ring-offset-zinc-950",
        "disabled:pointer-events-none disabled:opacity-50",
        sizing.button,
        !showLabel && cn(sizing.square, "px-0"),
        className
      )}
      type="button"
      {...props}
      onClick={handleClick}
    >
      <svg
        aria-hidden="true"
        className="pointer-events-none shrink-0 overflow-visible"
        fill="none"
        height={sizing.icon}
        ref={iconRef}
        viewBox="0 0 24 24"
        width={sizing.icon}
      >
        <mask id={maskId}>
          <rect fill="white" height="32" width="32" x="-4" y="-4" />
          <circle
            className="translate-x-[10px] -translate-y-[10px] dark:translate-x-0 dark:translate-y-0"
            cx="17"
            cy="7"
            data-part="bite"
            fill="black"
            r="7.5"
          />
        </mask>
        <g mask={`url(#${maskId})`}>
          <circle
            className="origin-[12px_12px] [transform-box:view-box] dark:scale-[1.8]"
            cx="12"
            cy="12"
            data-part="disc"
            fill="currentColor"
            r="4.75"
          />
        </g>
        <g stroke="currentColor" strokeLinecap="round" strokeWidth="2">
          {RAYS.map((ray, index) => (
            <line
              className="origin-[12px_12px] [transform-box:view-box] dark:scale-50 dark:opacity-0"
              data-index={index}
              data-part="ray"
              key={ray.deg}
              x1={ray.from.x}
              x2={ray.to.x}
              y1={ray.from.y}
              y2={ray.to.y}
            />
          ))}
        </g>
      </svg>
      {showLabel && (
        <span aria-hidden="true" className="grid leading-none" ref={labelRef}>
          <span
            className="col-start-1 row-start-1 dark:opacity-0 dark:blur-[3px]"
            data-part="label"
          >
            Light
          </span>
          <span
            className="col-start-1 row-start-1 opacity-0 blur-[3px] dark:opacity-100 dark:blur-none"
            data-part="label"
          >
            Dark
          </span>
        </span>
      )}
    </button>
  );
}

Switch Button

By Kokonut UI

Try the interaction, copy the React source files, or download the complete example.

About Switch Button

Switch Button is an animated React and Tailwind CSS button by Kokonut UI. Try the interaction, copy the React source, or download the example with creator credit.

Effects and categories
Hover effects, Animated
Using this example
Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.

Usage, Tailwind and licensing questions

Read the source files

import-examples/switch-button.tsx

import SwitchButtonDemo from "@/components/mdx/demos/switch-button-demo";
import { useTheme } from "next-themes";
import SwitchButton from "@/components/kokonutui/switch-button";
export default function ImportedDemo(){return <>
    <SwitchButtonDemo />
</>}

components/mdx/demos/switch-button-demo.tsx

"use client";

import { useTheme } from "next-themes";
import SwitchButton from "@/components/kokonutui/switch-button";

/**
 * Docs demo only. This site keeps its theme in next-themes, so the button is
 * wired to it; the component itself does not depend on next-themes.
 */
export default function SwitchButtonDemo() {
  const { resolvedTheme, setTheme } = useTheme();

  return (
    <SwitchButton
      onThemeChange={setTheme}
      theme={resolvedTheme === "dark" ? "dark" : "light"}
    />
  );
}

components/kokonutui/switch-button.tsx

"use client";

/**
 * @author: @dorianbaffier
 * @description: Switch Button
 * @version: 2.0.0
 * @date: 2026-10-16
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 *
 * Works on its own: it toggles the `dark` class on <html>. If a theme provider
 * owns the theme (next-themes or your own), pass `theme` and `onThemeChange`.
 *
 * The new theme spreads from the button as a circle (View Transitions API).
 * Without that API, or with reduced motion, the theme switches instantly.
 */

import { useEffect, useId, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { cn } from "@/lib/utils";

type Theme = "light" | "dark";

interface SwitchButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  size?: "sm" | "default" | "lg";
  showLabel?: boolean;
  /**
   * The applied theme, when a theme provider owns it (for next-themes, its
   * `resolvedTheme`). Pair it with `onThemeChange`. Without it the button
   * toggles the `dark` class on <html> itself.
   */
  theme?: Theme;
  /** Called with the theme to switch to. */
  onThemeChange?: (theme: Theme) => void;
  /** Spread the new theme from the button as a circle. */
  reveal?: boolean;
}

const SIZES = {
  sm: { button: "h-8 gap-1.5 px-2.5 text-[13px]", square: "w-8", icon: 14 },
  default: { button: "h-9 gap-2 px-3 text-sm", square: "w-9", icon: 16 },
  lg: { button: "h-10 gap-2 px-3.5 text-[15px]", square: "w-10", icon: 18 },
};

const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const EASE_IN_OUT = "cubic-bezier(0.65, 0, 0.35, 1)";
const EASE_REVEAL = "cubic-bezier(0.4, 0, 0.2, 1)";
const REVEAL_ATTR = "data-theme-reveal";
const REVEAL_STYLE_ID = "kokonut-theme-reveal";
const REVEAL_CSS = `html[${REVEAL_ATTR}]::view-transition-old(root),html[${REVEAL_ATTR}]::view-transition-new(root){animation:none;mix-blend-mode:normal}`;

const RAY_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315];
const RAYS = RAY_ANGLES.map((deg) => {
  const rad = (deg * Math.PI) / 180;
  const at = (r: number) => ({
    x: Number((12 + r * Math.cos(rad)).toFixed(2)),
    y: Number((12 + r * Math.sin(rad)).toFixed(2)),
  });
  return { deg, from: at(7.5), to: at(10) };
});

// Each part's light look is its base class and its dark look a `dark:` class,
// so the server render (theme unknown) matches the client. Clicking animates
// from the old computed values to the new ones with WAAPI, which keeps
// working when a provider disables CSS transitions while it swaps themes.
const ANIMATED_PROPS = ["opacity", "scale", "translate", "filter"] as const;

type Snapshot = Map<Element, Keyframe>;

function snapshot(elements: Element[]): Snapshot {
  const map: Snapshot = new Map();
  for (const el of elements) {
    const style = getComputedStyle(el);
    const frame: Keyframe = { offset: 0 };
    for (const prop of ANIMATED_PROPS) {
      frame[prop] = style.getPropertyValue(prop);
    }
    map.set(el, frame);
    for (const animation of el.getAnimations()) {
      animation.cancel();
    }
  }
  return map;
}

function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function ensureRevealStyle() {
  if (document.getElementById(REVEAL_STYLE_ID)) {
    return;
  }
  const style = document.createElement("style");
  style.id = REVEAL_STYLE_ID;
  style.textContent = REVEAL_CSS;
  document.head.appendChild(style);
}

function timing(el: Element, toDark: boolean): KeyframeAnimationOptions {
  const { part, index = "0" } = (el as HTMLElement | SVGElement).dataset;
  const i = Number(index);
  switch (part) {
    case "ray":
      // Rays retract one after another; on the way back they wait for the
      // bite to clear first.
      return {
        delay: toDark ? i * 18 : 140 + i * 22,
        duration: 260,
        easing: EASE_OUT,
      };
    case "bite":
      return { delay: toDark ? 60 : 0, duration: 380, easing: EASE_IN_OUT };
    case "disc":
      return { delay: toDark ? 40 : 0, duration: 380, easing: EASE_OUT };
    default:
      return { duration: 240, easing: EASE_OUT };
  }
}

function morph(from: Snapshot, toDark: boolean) {
  if (prefersReducedMotion()) {
    return;
  }
  for (const [el, frame] of from) {
    el.animate([frame, {}], { ...timing(el, toDark), fill: "backwards" });
  }
}

let revealRunning = false;

function revealFrom(button: HTMLElement, apply: () => void) {
  const doc = document as Document & { activeViewTransition?: unknown };
  if (
    typeof doc.startViewTransition !== "function" ||
    prefersReducedMotion() ||
    revealRunning ||
    doc.activeViewTransition
  ) {
    apply();
    return;
  }

  const rect = button.getBoundingClientRect();
  const x = rect.left + rect.width / 2;
  const y = rect.top + rect.height / 2;
  const radius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
  const root = document.documentElement;

  ensureRevealStyle();
  root.setAttribute(REVEAL_ATTR, "");
  revealRunning = true;

  let transition: ViewTransition;
  try {
    transition = doc.startViewTransition(() => flushSync(apply));
  } catch {
    root.removeAttribute(REVEAL_ATTR);
    revealRunning = false;
    apply();
    return;
  }

  transition.ready
    .then(() => {
      root.animate(
        {
          clipPath: [
            `circle(0px at ${x}px ${y}px)`,
            `circle(${radius}px at ${x}px ${y}px)`,
          ],
        },
        {
          duration: 500,
          easing: EASE_REVEAL,
          pseudoElement: "::view-transition-new(root)",
        }
      );
    })
    .catch(() => {
      // Skipped or aborted (e.g. a page navigation started a transition).
    });

  transition.finished
    .catch(() => {
      // The theme is already applied by the time a transition can fail.
    })
    .finally(() => {
      root.removeAttribute(REVEAL_ATTR);
      revealRunning = false;
    });
}

export default function SwitchButton({
  className,
  size = "default",
  showLabel = true,
  theme,
  onThemeChange,
  reveal = true,
  onClick,
  ...props
}: SwitchButtonProps) {
  const maskId = `${useId()}-moon`;
  const iconRef = useRef<SVGSVGElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const [observed, setObserved] = useState<Theme | null>(null);

  // Only for aria-pressed: read the applied theme after mount, so the first
  // client render matches the server.
  useEffect(() => {
    const root = document.documentElement;
    const read = () =>
      setObserved(root.classList.contains("dark") ? "dark" : "light");
    read();
    const observer = new MutationObserver(read);
    observer.observe(root, { attributes: true, attributeFilter: ["class"] });
    return () => observer.disconnect();
  }, []);

  const current = observed && (theme ?? observed);
  const sizing = SIZES[size];

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    onClick?.(e);
    if (e.defaultPrevented) {
      return;
    }
    const root = document.documentElement;
    const from = theme ?? (root.classList.contains("dark") ? "dark" : "light");
    const next: Theme = from === "dark" ? "light" : "dark";
    const button = e.currentTarget;

    const apply = () => {
      const parts = [
        ...(iconRef.current?.querySelectorAll("[data-part]") ?? []),
        ...(labelRef.current?.querySelectorAll("[data-part]") ?? []),
      ];
      const before = snapshot(parts);
      // With a provider the class is also set here, because next-themes
      // applies it in an effect: the reveal's snapshot needs it now. The
      // provider then sets the same value.
      root.classList.toggle("dark", next === "dark");
      root.style.colorScheme = next;
      onThemeChange?.(next);
      morph(before, next === "dark");
    };

    if (reveal) {
      revealFrom(button, apply);
    } else {
      apply();
    }
  };

  return (
    <button
      aria-label="Dark theme"
      aria-pressed={current ? current === "dark" : undefined}
      className={cn(
        "group/switch relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center rounded-lg font-medium",
        "border border-zinc-200 bg-white text-zinc-900 shadow-xs",
        "dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100",
        "hover:border-zinc-300 hover:bg-zinc-50 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/70",
        "active:bg-zinc-100 dark:active:bg-zinc-800",
        "transition-[background-color,border-color] duration-150 ease-out",
        "outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-zinc-500 dark:focus-visible:ring-offset-zinc-950",
        "disabled:pointer-events-none disabled:opacity-50",
        sizing.button,
        !showLabel && cn(sizing.square, "px-0"),
        className
      )}
      type="button"
      {...props}
      onClick={handleClick}
    >
      <svg
        aria-hidden="true"
        className="pointer-events-none shrink-0 overflow-visible"
        fill="none"
        height={sizing.icon}
        ref={iconRef}
        viewBox="0 0 24 24"
        width={sizing.icon}
      >
        <mask id={maskId}>
          <rect fill="white" height="32" width="32" x="-4" y="-4" />
          <circle
            className="translate-x-[10px] -translate-y-[10px] dark:translate-x-0 dark:translate-y-0"
            cx="17"
            cy="7"
            data-part="bite"
            fill="black"
            r="7.5"
          />
        </mask>
        <g mask={`url(#${maskId})`}>
          <circle
            className="origin-[12px_12px] [transform-box:view-box] dark:scale-[1.8]"
            cx="12"
            cy="12"
            data-part="disc"
            fill="currentColor"
            r="4.75"
          />
        </g>
        <g stroke="currentColor" strokeLinecap="round" strokeWidth="2">
          {RAYS.map((ray, index) => (
            <line
              className="origin-[12px_12px] [transform-box:view-box] dark:scale-50 dark:opacity-0"
              data-index={index}
              data-part="ray"
              key={ray.deg}
              x1={ray.from.x}
              x2={ray.to.x}
              y1={ray.from.y}
              y2={ray.to.y}
            />
          ))}
        </g>
      </svg>
      {showLabel && (
        <span aria-hidden="true" className="grid leading-none" ref={labelRef}>
          <span
            className="col-start-1 row-start-1 dark:opacity-0 dark:blur-[3px]"
            data-part="label"
          >
            Light
          </span>
          <span
            className="col-start-1 row-start-1 opacity-0 blur-[3px] dark:opacity-100 dark:blur-none"
            data-part="label"
          >
            Dark
          </span>
        </span>
      )}
    </button>
  );
}

lib/utils.ts

import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

demo.tsx

export { default as default } from "@/import-examples/switch-button";

CSS

/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
  :root, :host {
    --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
      "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
      "Segoe UI Symbol", "Noto Color Emoji";
    --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
      "Courier New", monospace;
    --color-zinc-50: oklch(98.5% 0 none);
    --color-zinc-100: oklch(96.7% 0.001 286.375);
    --color-zinc-200: oklch(92% 0.004 286.32);
    --color-zinc-300: oklch(87.1% 0.006 286.286);
    --color-zinc-400: oklch(70.5% 0.015 286.067);
    --color-zinc-500: oklch(55.2% 0.016 285.938);
    --color-zinc-700: oklch(37% 0.013 285.805);
    --color-zinc-800: oklch(27.4% 0.006 286.033);
    --color-zinc-900: oklch(21% 0.006 285.885);
    --color-zinc-950: oklch(14.1% 0.005 285.823);
    --color-white: #fff;
    --spacing: 0.25rem;
    --text-sm: 0.875rem;
    --text-sm--line-height: calc(1.25 / 0.875);
    --font-weight-medium: 500;
    --radius-lg: var(--radius);
    --ease-out: cubic-bezier(0, 0, 0.2, 1);
    --default-transition-duration: 150ms;
    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    --default-font-family: var(--font-sans);
    --default-mono-font-family: var(--font-mono);
  }
}
@layer base {
  *, ::after, ::before, ::backdrop, ::file-selector-button {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    border: 0 solid;
  }
  html, :host {
    line-height: 1.5;
    -webkit-text-size-adjust: 100%;
    tab-size: 4;
    font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
    font-feature-settings: var(--default-font-feature-settings, normal);
    font-variation-settings: var(--default-font-variation-settings, normal);
    -webkit-tap-highlight-color: transparent;
  }
  hr {
    height: 0;
    color: inherit;
    border-top-width: 1px;
  }
  abbr:where([title]) {
    -webkit-text-decoration: underline dotted;
    text-decoration: underline dotted;
  }
  h1, h2, h3, h4, h5, h6 {
    font-size: inherit;
    font-weight: inherit;
  }
  a {
    color: inherit;
    -webkit-text-decoration: inherit;
    text-decoration: inherit;
  }
  b, strong {
    font-weight: bolder;
  }
  code, kbd, samp, pre {
    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
    font-feature-settings: var(--default-mono-font-feature-settings, normal);
    font-variation-settings: var(--default-mono-font-variation-settings, normal);
    font-size: 1em;
  }
  small {
    font-size: 80%;
  }
  sub, sup {
    font-size: 75%;
    line-height: 0;
    position: relative;
    vertical-align: baseline;
  }
  sub {
    bottom: -0.25em;
  }
  sup {
    top: -0.5em;
  }
  table {
    text-indent: 0;
    border-color: inherit;
    border-collapse: collapse;
  }
  :-moz-focusring:where(:not(iframe)) {
    outline: auto;
  }
  progress {
    vertical-align: baseline;
  }
  summary {
    display: list-item;
  }
  ol, ul, menu {
    list-style: none;
  }
  img, svg, video, canvas, audio, iframe, embed, object {
    display: block;
    vertical-align: middle;
  }
  img, video {
    max-width: 100%;
    height: auto;
  }
  button, input, select, optgroup, textarea, ::file-selector-button {
    font: inherit;
    font-feature-settings: inherit;
    font-variation-settings: inherit;
    letter-spacing: inherit;
    color: inherit;
    border-radius: 0;
    background-color: transparent;
    opacity: 1;
  }
  :where(select:is([multiple], [size])) optgroup {
    font-weight: bolder;
  }
  :where(select:is([multiple], [size])) optgroup option {
    padding-inline-start: 20px;
  }
  ::file-selector-button {
    margin-inline-end: 4px;
  }
  ::placeholder {
    opacity: 1;
  }
  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {
    ::placeholder {
      color: currentcolor;
      @supports (color: color-mix(in lab, red, red)) {
        color: color-mix(in oklab, currentcolor 50%, transparent);
      }
    }
  }
  textarea {
    resize: vertical;
  }
  ::-webkit-search-decoration {
    -webkit-appearance: none;
  }
  ::-webkit-date-and-time-value {
    min-height: 1lh;
    text-align: inherit;
  }
  ::-webkit-datetime-edit {
    display: inline-flex;
  }
  ::-webkit-datetime-edit-fields-wrapper {
    padding: 0;
  }
  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
    padding-block: 0;
  }
  ::-webkit-calendar-picker-indicator {
    line-height: 1;
  }
  :-moz-ui-invalid {
    box-shadow: none;
  }
  button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
    appearance: button;
  }
  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
    height: auto;
  }
  [hidden]:where(:not([hidden="until-found"])) {
    display: none !important;
  }
}
@layer utilities {
  .pointer-events-none {
    pointer-events: none;
  }
  .relative {
    position: relative;
  }
  .col-start-1 {
    grid-column-start: 1;
  }
  .row-start-1 {
    grid-row-start: 1;
  }
  .grid {
    display: grid;
  }
  .inline-flex {
    display: inline-flex;
  }
  .h-8 {
    height: calc(var(--spacing) * 8);
  }
  .h-9 {
    height: calc(var(--spacing) * 9);
  }
  .h-10 {
    height: calc(var(--spacing) * 10);
  }
  .w-8 {
    width: calc(var(--spacing) * 8);
  }
  .w-9 {
    width: calc(var(--spacing) * 9);
  }
  .w-10 {
    width: calc(var(--spacing) * 10);
  }
  .shrink-0 {
    flex-shrink: 0;
  }
  .origin-\[12px_12px\] {
    transform-origin: 12px 12px;
  }
  .translate-x-\[10px\] {
    --tw-translate-x: 10px;
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .-translate-y-\[10px\] {
    --tw-translate-y: calc(10px * -1);
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .cursor-pointer {
    cursor: pointer;
  }
  .items-center {
    align-items: center;
  }
  .justify-center {
    justify-content: center;
  }
  .gap-1\.5 {
    gap: calc(var(--spacing) * 1.5);
  }
  .gap-2 {
    gap: calc(var(--spacing) * 2);
  }
  .overflow-visible {
    overflow: visible;
  }
  .rounded-lg {
    border-radius: var(--radius-lg);
  }
  .border {
    border-style: var(--tw-border-style);
    border-width: 1px;
  }
  .border-zinc-200 {
    border-color: var(--color-zinc-200);
  }
  .bg-white {
    background-color: var(--color-white);
  }
  .px-0 {
    padding-inline: 0px;
  }
  .px-2\.5 {
    padding-inline: calc(var(--spacing) * 2.5);
  }
  .px-3 {
    padding-inline: calc(var(--spacing) * 3);
  }
  .px-3\.5 {
    padding-inline: calc(var(--spacing) * 3.5);
  }
  .text-sm {
    font-size: var(--text-sm);
    line-height: var(--tw-leading, var(--text-sm--line-height));
  }
  .text-\[13px\] {
    font-size: 13px;
  }
  .text-\[15px\] {
    font-size: 15px;
  }
  .leading-none {
    --tw-leading: 1;
    line-height: 1;
  }
  .font-medium {
    --tw-font-weight: var(--font-weight-medium);
    font-weight: var(--font-weight-medium);
  }
  .text-zinc-900 {
    color: var(--color-zinc-900);
  }
  .opacity-0 {
    opacity: 0%;
  }
  .shadow-xs {
    --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
  }
  .blur-\[3px\] {
    --tw-blur: blur(3px);
    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
  }
  .filter {
    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
  }
  .transition {
    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
    transition-duration: var(--tw-duration, var(--default-transition-duration));
  }
  .transition-\[background-color\,border-color\] {
    transition-property: background-color,border-color;
    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
    transition-duration: var(--tw-duration, var(--default-transition-duration));
  }
  .duration-150 {
    --tw-duration: 150ms;
    transition-duration: 150ms;
  }
  .ease-out {
    --tw-ease: var(--ease-out);
    transition-timing-function: var(--ease-out);
  }
  .outline-none {
    --tw-outline-style: none;
    outline-style: none;
  }
  .select-none {
    -webkit-user-select: none;
    user-select: none;
  }
  .\[transform-box\:view-box\] {
    transform-box: view-box;
  }
  @media (hover: hover) {
    .hover\:border-zinc-300:hover {
      border-color: var(--color-zinc-300);
    }
    .hover\:bg-zinc-50:hover {
      background-color: var(--color-zinc-50);
    }
  }
  .focus-visible\:ring-2:focus-visible {
    --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
  }
  .focus-visible\:ring-zinc-400:focus-visible {
    --tw-ring-color: var(--color-zinc-400);
  }
  .focus-visible\:ring-offset-2:focus-visible {
    --tw-ring-offset-width: 2px;
    --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
  }
  .focus-visible\:ring-offset-white:focus-visible {
    --tw-ring-offset-color: var(--color-white);
  }
  .active\:bg-zinc-100:active {
    background-color: var(--color-zinc-100);
  }
  .disabled\:pointer-events-none:disabled {
    pointer-events: none;
  }
  .disabled\:opacity-50:disabled {
    opacity: 50%;
  }
  .dark\:translate-x-0:where(.dark, .dark *) {
    --tw-translate-x: 0px;
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .dark\:translate-y-0:where(.dark, .dark *) {
    --tw-translate-y: 0px;
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .dark\:scale-50:where(.dark, .dark *) {
    --tw-scale-x: 50%;
    --tw-scale-y: 50%;
    --tw-scale-z: 50%;
    scale: var(--tw-scale-x) var(--tw-scale-y);
  }
  .dark\:scale-\[1\.8\]:where(.dark, .dark *) {
    scale: 1.8;
  }
  .dark\:border-zinc-800:where(.dark, .dark *) {
    border-color: var(--color-zinc-800);
  }
  .dark\:bg-zinc-900:where(.dark, .dark *) {
    background-color: var(--color-zinc-900);
  }
  .dark\:text-zinc-100:where(.dark, .dark *) {
    color: var(--color-zinc-100);
  }
  .dark\:opacity-0:where(.dark, .dark *) {
    opacity: 0%;
  }
  .dark\:opacity-100:where(.dark, .dark *) {
    opacity: 100%;
  }
  .dark\:blur-\[3px\]:where(.dark, .dark *) {
    --tw-blur: blur(3px);
    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
  }
  .dark\:blur-none:where(.dark, .dark *) {
    --tw-blur:  ;
    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
  }
  @media (hover: hover) {
    .dark\:hover\:border-zinc-700:where(.dark, .dark *):hover {
      border-color: var(--color-zinc-700);
    }
    .dark\:hover\:bg-zinc-800\/70:where(.dark, .dark *):hover {
      background-color: color-mix(in srgb, oklch(27.4% 0.006 286.033) 70%, transparent);
    }
    @supports (color: color-mix(in lab, red, red)) {
      .dark\:hover\:bg-zinc-800\/70:where(.dark, .dark *):hover {
        background-color: color-mix(in oklab, var(--color-zinc-800) 70%, transparent);
      }
    }
  }
  .dark\:focus-visible\:ring-zinc-500:where(.dark, .dark *):focus-visible {
    --tw-ring-color: var(--color-zinc-500);
  }
  .dark\:focus-visible\:ring-offset-zinc-950:where(.dark, .dark *):focus-visible {
    --tw-ring-offset-color: var(--color-zinc-950);
  }
  .dark\:active\:bg-zinc-800:where(.dark, .dark *):active {
    background-color: var(--color-zinc-800);
  }
}
:root {
  --background: #fff;
  --foreground: #111;
  --primary: #171717;
  --primary-foreground: #fff;
  --secondary: #eee;
  --secondary-foreground: #171717;
  --muted: #eee;
  --muted-foreground: #666;
  --accent: #eee;
  --accent-foreground: #171717;
  --border: #ddd;
  --input: #ddd;
  --ring: #777;
  --destructive: #dc2626;
  --destructive-foreground: #fff;
  --popover: #fff;
  --popover-foreground: #111;
  --success: #15803d;
  --success-foreground: #fff;
  --radius: .625rem;
  --hairline: 1px;
}
.dark {
  --background: #090909;
  --foreground: #fafafa;
  --primary: #fafafa;
  --primary-foreground: #171717;
  --secondary: #262626;
  --secondary-foreground: #fafafa;
  --muted: #262626;
  --muted-foreground: #a3a3a3;
  --accent: #262626;
  --accent-foreground: #fafafa;
  --border: #333;
  --input: #333;
  --popover: #171717;
  --popover-foreground: #fafafa;
  --color-fd-background: #000;
  --color-fd-card: #0a0a0a;
  --color-fd-popover: #0a0a0a;
  --color-fd-border: rgb(255 255 255 / 0.14);
  --color-fd-accent: rgb(255 255 255 / 0.07);
}
:root {
  --sidebar: hsl(0 0% 98%);
  --sidebar-foreground: hsl(240 5.3% 26.1%);
  --sidebar-primary: hsl(240 5.9% 10%);
  --sidebar-primary-foreground: hsl(0 0% 98%);
  --sidebar-accent: hsl(240 4.8% 95.9%);
  --sidebar-accent-foreground: hsl(240 5.9% 10%);
  --sidebar-border: hsl(220 13% 91%);
  --sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
  --sidebar: hsl(240 5.9% 10%);
  --sidebar-foreground: hsl(240 4.8% 95.9%);
  --sidebar-primary: hsl(224.3 76.3% 48%);
  --sidebar-primary-foreground: hsl(0 0% 100%);
  --sidebar-accent: hsl(240 3.7% 15.9%);
  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
  --sidebar-border: hsl(240 3.7% 15.9%);
  --sidebar-ring: hsl(217.2 91.2% 59.8%);
}
:root {
  --leaf: #1fa834;
}
.dark {
  --leaf: #3bb44b;
}
@property --tw-translate-x {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}
@property --tw-translate-y {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}
@property --tw-translate-z {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}
@property --tw-border-style {
  syntax: "*";
  inherits: false;
  initial-value: solid;
}
@property --tw-leading {
  syntax: "*";
  inherits: false;
}
@property --tw-font-weight {
  syntax: "*";
  inherits: false;
}
@property --tw-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
  syntax: "*";
  inherits: false;
}
@property --tw-shadow-alpha {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
@property --tw-inset-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
  syntax: "*";
  inherits: false;
}
@property --tw-inset-shadow-alpha {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
@property --tw-ring-color {
  syntax: "*";
  inherits: false;
}
@property --tw-ring-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
  syntax: "*";
  inherits: false;
}
@property --tw-inset-ring-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
  syntax: "*";
  inherits: false;
}
@property --tw-ring-offset-width {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
@property --tw-ring-offset-color {
  syntax: "*";
  inherits: false;
  initial-value: #fff;
}
@property --tw-ring-offset-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-blur {
  syntax: "*";
  inherits: false;
}
@property --tw-brightness {
  syntax: "*";
  inherits: false;
}
@property --tw-contrast {
  syntax: "*";
  inherits: false;
}
@property --tw-grayscale {
  syntax: "*";
  inherits: false;
}
@property --tw-hue-rotate {
  syntax: "*";
  inherits: false;
}
@property --tw-invert {
  syntax: "*";
  inherits: false;
}
@property --tw-opacity {
  syntax: "*";
  inherits: false;
}
@property --tw-saturate {
  syntax: "*";
  inherits: false;
}
@property --tw-sepia {
  syntax: "*";
  inherits: false;
}
@property --tw-drop-shadow {
  syntax: "*";
  inherits: false;
}
@property --tw-drop-shadow-color {
  syntax: "*";
  inherits: false;
}
@property --tw-drop-shadow-alpha {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
@property --tw-drop-shadow-size {
  syntax: "*";
  inherits: false;
}
@property --tw-duration {
  syntax: "*";
  inherits: false;
}
@property --tw-ease {
  syntax: "*";
  inherits: false;
}
@property --tw-scale-x {
  syntax: "*";
  inherits: false;
  initial-value: 1;
}
@property --tw-scale-y {
  syntax: "*";
  inherits: false;
  initial-value: 1;
}
@property --tw-scale-z {
  syntax: "*";
  inherits: false;
  initial-value: 1;
}
@layer properties {
  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
    *, ::before, ::after, ::backdrop {
      --tw-translate-x: 0;
      --tw-translate-y: 0;
      --tw-translate-z: 0;
      --tw-border-style: solid;
      --tw-leading: initial;
      --tw-font-weight: initial;
      --tw-shadow: 0 0 #0000;
      --tw-shadow-color: initial;
      --tw-shadow-alpha: 100%;
      --tw-inset-shadow: 0 0 #0000;
      --tw-inset-shadow-color: initial;
      --tw-inset-shadow-alpha: 100%;
      --tw-ring-color: initial;
      --tw-ring-shadow: 0 0 #0000;
      --tw-inset-ring-color: initial;
      --tw-inset-ring-shadow: 0 0 #0000;
      --tw-ring-inset: initial;
      --tw-ring-offset-width: 0px;
      --tw-ring-offset-color: #fff;
      --tw-ring-offset-shadow: 0 0 #0000;
      --tw-blur: initial;
      --tw-brightness: initial;
      --tw-contrast: initial;
      --tw-grayscale: initial;
      --tw-hue-rotate: initial;
      --tw-invert: initial;
      --tw-opacity: initial;
      --tw-saturate: initial;
      --tw-sepia: initial;
      --tw-drop-shadow: initial;
      --tw-drop-shadow-color: initial;
      --tw-drop-shadow-alpha: 100%;
      --tw-drop-shadow-size: initial;
      --tw-duration: initial;
      --tw-ease: initial;
      --tw-scale-x: 1;
      --tw-scale-y: 1;
      --tw-scale-z: 1;
    }
  }
}