Back to buttons
"use client";

/**
 * @author: @dorianbaffier
 * @description: Save Button
 * @version: 1.0.0
 * @date: 2026-10-21
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 */

import {Check,Plus} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  type FormEvent,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";

const EASE_OUT = [0.23, 1, 0.32, 1] as const;
/** How long the "Saved to" strip stays up when left alone. */
const STRIP_FOR = 4000;

export interface SaveCollection {
  id: string;
  name: string;
  /** Items already in it, before this one. */
  count: number;
  /** Tailwind background class for its swatch. */
  color: string;
}

export interface SaveButtonProps {
  collections?: SaveCollection[];
  /** Where a tap saves to, by id. Defaults to the first collection. */
  defaultCollection?: string;
  defaultSaved?: boolean;
  onSave?: (collectionId: string) => void;
  onMove?: (collectionId: string) => void;
  onRemove?: () => void;
  className?: string;
}

const DEFAULT_COLLECTIONS: SaveCollection[] = [
  { id: "weekend", name: "Weekend trips", count: 12, color: "bg-orange-400" },
  { id: "tokyo", name: "Tokyo 2027", count: 31, color: "bg-sky-400" },
  { id: "food", name: "Places to eat", count: 48, color: "bg-emerald-400" },
  { id: "someday", name: "Someday", count: 7, color: "bg-violet-400" },
];

const SWATCHES = [
  "bg-rose-400",
  "bg-amber-300",
  "bg-teal-400",
  "bg-indigo-400",
];

type View = "closed" | "saved" | "picking" | "removed";

// The panel sits under AnimatePresence, so it's found by query, not ref.
const findPanel = (root: HTMLElement | null) =>
  root?.querySelector("[data-save-panel]") ?? null;

const focusInPanel = (root: HTMLElement | null) =>
  Boolean(findPanel(root)?.contains(document.activeElement));

// Whatever closes or swaps the panel's content would drop focus on <body>,
// so it goes back to the button first.
const returnFocus = (root: HTMLElement | null, button: HTMLElement | null) => {
  if (focusInPanel(root)) {
    button?.focus();
  }
};

/** A bookmark whose ink drops in from the top like a ribbon when saved. */
function Ribbon({
  saved,
  reduceMotion,
}: {
  saved: boolean;
  reduceMotion: boolean;
}) {
  const clip = useId();
  let ribbonTransition: object = { duration: 0.18, ease: EASE_OUT };
  if (reduceMotion) {
    ribbonTransition = { duration: 0 };
  } else if (saved) {
    ribbonTransition = { type: "spring", stiffness: 520, damping: 24 };
  }
  return (
    <svg
      aria-hidden
      className="size-4 overflow-visible"
      fill="none"
      viewBox="0 0 16 16"
    >
      <title>Bookmark</title>
      <defs>
        <clipPath id={clip}>
          <path d="M3.5 2.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v11.2l-4.5-3-4.5 3z" />
        </clipPath>
      </defs>
      <g clipPath={`url(#${clip})`}>
        <motion.rect
          animate={{ y: saved ? 0 : -16 }}
          className="fill-current"
          height="16"
          initial={false}
          transition={ribbonTransition}
          width="16"
          x="0"
          y="0"
        />
      </g>
      <path
        className="stroke-current"
        d="M3.5 2.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v11.2l-4.5-3-4.5 3z"
        strokeLinejoin="round"
        strokeWidth="1.5"
      />
    </svg>
  );
}

export default function SaveButton({
  collections: initialCollections = DEFAULT_COLLECTIONS,
  defaultCollection,
  defaultSaved = false,
  onSave,
  onMove,
  onRemove,
  className,
}: SaveButtonProps) {
  const [collections, setCollections] = useState(initialCollections);
  const [savedTo, setSavedTo] = useState<string | null>(
    defaultSaved
      ? (defaultCollection ?? initialCollections[0]?.id ?? null)
      : null
  );
  const [lastUsed, setLastUsed] = useState(
    defaultCollection ?? initialCollections[0]?.id ?? ""
  );
  const [view, setView] = useState<View>("closed");
  const [draft, setDraft] = useState("");
  const [moved, setMoved] = useState(false);
  const reduceMotion = Boolean(useReducedMotion());

  const rootRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const hoverRef = useRef(false);
  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

  const contentRef = useRef<HTMLDivElement>(null);
  const isOpen = view !== "closed";
  const [contentHeight, setContentHeight] = useState<number>();

  // The strip grows into the picker and back by its measured height.
  useLayoutEffect(() => {
    const content = contentRef.current;
    if (!(isOpen && content)) {
      setContentHeight(undefined);
      return;
    }
    const observer = new ResizeObserver(() =>
      setContentHeight(content.offsetHeight)
    );
    observer.observe(content);
    return () => observer.disconnect();
  }, [isOpen]);

  const saved = savedTo !== null;
  const current = collections.find((c) => c.id === savedTo);

  // The strip closes on its own unless it is being read or used.
  const schedule = (ms = STRIP_FOR) => {
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      if (!(hoverRef.current || focusInPanel(rootRef.current))) {
        setView((v) => (v === "picking" ? v : "closed"));
      }
    }, ms);
  };

  useEffect(() => () => clearTimeout(timerRef.current), []);

  useEffect(() => {
    if (view === "closed") {
      return;
    }
    const onPointer = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) {
        setView("closed");
      }
    };
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        returnFocus(rootRef.current, buttonRef.current);
        setView("closed");
      }
    };
    document.addEventListener("pointerdown", onPointer);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointer);
      document.removeEventListener("keydown", onKey);
    };
  }, [view]);

  // Opening the picker moves focus onto the collection it's saved in.
  useEffect(() => {
    if (view === "picking") {
      findPanel(rootRef.current)
        ?.querySelector<HTMLButtonElement>('[aria-current="true"]')
        ?.focus();
    }
  }, [view]);

  const toggle = () => {
    if (saved) {
      setSavedTo(null);
      setView("removed");
      onRemove?.();
      schedule(1600);
      return;
    }
    setSavedTo(lastUsed);
    setMoved(false);
    setView("saved");
    onSave?.(lastUsed);
    schedule();
  };

  const moveTo = (id: string) => {
    returnFocus(rootRef.current, buttonRef.current);
    setSavedTo(id);
    setLastUsed(id);
    setMoved(id !== savedTo);
    setView("saved");
    if (id !== savedTo) {
      onMove?.(id);
    }
    schedule();
  };

  const create = (event: FormEvent) => {
    event.preventDefault();
    const name = draft.trim();
    if (!name) {
      return;
    }
    const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${collections.length}`;
    const color = SWATCHES[collections.length % SWATCHES.length];
    setCollections((list) => [...list, { id, name, count: 0, color }]);
    setDraft("");
    moveTo(id);
  };

  const countFor = (collection: SaveCollection) =>
    collection.count + (savedTo === collection.id ? 1 : 0);

  const swap = reduceMotion
    ? { duration: 0 }
    : { duration: 0.2, ease: EASE_OUT };

  return (
    <div
      className={cn("relative inline-flex", className)}
      onPointerEnter={() => {
        hoverRef.current = true;
      }}
      onPointerLeave={() => {
        hoverRef.current = false;
        if (view === "saved" || view === "removed") {
          schedule(1800);
        }
      }}
      ref={rootRef}
    >
      <button
        aria-pressed={saved}
        className={cn(
          "inline-flex h-10 items-center gap-2 rounded-full border pr-4 pl-3.5 font-medium text-sm outline-none",
          "transition-colors duration-150 ease-out",
          "focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950",
          saved
            ? "border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800 dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
            : "border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-900"
        )}
        onClick={toggle}
        ref={buttonRef}
        type="button"
      >
        <Ribbon reduceMotion={reduceMotion} saved={saved} />
        <span className="relative inline-flex overflow-hidden">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: saved ? -14 : 14, opacity: 0 }}
              initial={{ y: saved ? 14 : -14, opacity: 0 }}
              key={saved ? "saved" : "save"}
              transition={swap}
            >
              {saved ? "Saved" : "Save"}
            </motion.span>
          </AnimatePresence>
        </span>
      </button>

      <AnimatePresence>
        {view !== "closed" && (
          <motion.div
            animate={{ opacity: 1, y: 0, scale: 1 }}
            className="absolute top-full left-1/2 z-20 mt-2 w-72 origin-top -translate-x-1/2 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-[0_8px_24px_-8px_rgb(0_0_0/0.6)]"
            data-save-panel
            exit={{
              opacity: 0,
              y: -4,
              scale: 0.98,
              transition: { duration: 0.12 },
            }}
            initial={{ opacity: 0, y: -6, scale: 0.97 }}
            transition={
              reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }
            }
          >
            <motion.div
              animate={{ height: contentHeight ?? "auto" }}
              initial={false}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.26, ease: EASE_OUT }
              }
            >
              <div ref={contentRef}>
                {view === "picking" ? (
                  <Picker
                    collections={collections}
                    countFor={countFor}
                    create={create}
                    draft={draft}
                    moveTo={moveTo}
                    savedTo={savedTo}
                    setDraft={setDraft}
                  />
                ) : (
                  <div
                    aria-live="polite"
                    className="flex h-12 items-center gap-2.5 pr-2 pl-3 text-sm"
                  >
                    {view === "removed" || !current ? (
                      <span className="text-zinc-500 dark:text-zinc-400">
                        Removed from saved
                      </span>
                    ) : (
                      <>
                        <span
                          className={cn(
                            "size-5 shrink-0 rounded-md",
                            current.color
                          )}
                        />
                        <span className="min-w-0 flex-1 truncate text-zinc-500 dark:text-zinc-400">
                          {moved ? "Moved to " : "Saved to "}
                          <span className="relative inline-flex align-bottom">
                            <AnimatePresence initial={false} mode="popLayout">
                              <motion.span
                                animate={{ y: 0, opacity: 1 }}
                                className="font-medium text-zinc-900 dark:text-zinc-100"
                                exit={{ y: -10, opacity: 0 }}
                                initial={{ y: 10, opacity: 0 }}
                                key={current.id}
                                transition={swap}
                              >
                                {current.name}
                              </motion.span>
                            </AnimatePresence>
                          </span>
                        </span>
                        <button
                          className="h-8 shrink-0 rounded-full px-3 font-medium text-xs text-zinc-900 transition-colors duration-150 hover:bg-zinc-100 dark:text-zinc-100 dark:hover:bg-zinc-900"
                          onClick={() => {
                            clearTimeout(timerRef.current);
                            setView("picking");
                          }}
                          type="button"
                        >
                          Change
                        </button>
                      </>
                    )}
                  </div>
                )}
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function Picker({
  collections,
  savedTo,
  countFor,
  moveTo,
  draft,
  setDraft,
  create,
}: {
  collections: SaveCollection[];
  savedTo: string | null;
  countFor: (collection: SaveCollection) => number;
  moveTo: (id: string) => void;
  draft: string;
  setDraft: (value: string) => void;
  create: (event: FormEvent) => void;
}) {
  return (
    <div className="p-1.5">
      <p className="px-2 pt-1.5 pb-1 text-[11px] text-zinc-400 dark:text-zinc-500">
        Save to
      </p>
      <ul className="max-h-56 overflow-y-auto">
        {collections.map((collection) => {
          const active = collection.id === savedTo;
          return (
            <li key={collection.id}>
              <button
                aria-current={active}
                className="flex h-11 w-full items-center gap-2.5 rounded-xl px-2 text-left text-sm outline-none transition-colors duration-150 hover:bg-zinc-100 focus-visible:bg-zinc-100 dark:focus-visible:bg-zinc-900 dark:hover:bg-zinc-900"
                onClick={() => moveTo(collection.id)}
                type="button"
              >
                <span
                  className={cn("size-7 shrink-0 rounded-lg", collection.color)}
                />
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-zinc-900 dark:text-zinc-100">
                    {collection.name}
                  </span>
                  <span className="block text-[11px] text-zinc-400 tabular-nums dark:text-zinc-500">
                    {countFor(collection)} saved
                  </span>
                </span>
                {active && (
                  <Check
                    aria-hidden
                    className="size-4 text-zinc-900 dark:text-zinc-100"
                    strokeWidth={2.5}
                  />
                )}
              </button>
            </li>
          );
        })}
      </ul>
      <form
        className="mt-1 flex h-11 items-center gap-2.5 rounded-xl border-zinc-100 border-t px-2 pt-1 dark:border-zinc-900"
        onSubmit={create}
      >
        <span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-zinc-300 border-dashed text-zinc-400 dark:border-zinc-700">
          <Plus aria-hidden className="size-3.5" />
        </span>
        <input
          aria-label="New collection"
          className="min-w-0 flex-1 bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
          onChange={(event) => setDraft(event.target.value)}
          placeholder="New collection"
          value={draft}
        />
        <AnimatePresence initial={false}>
          {draft.trim() && (
            <motion.button
              animate={{ opacity: 1, x: 0 }}
              className="h-7 shrink-0 rounded-full bg-zinc-900 px-2.5 font-medium text-[11px] text-white dark:bg-zinc-100 dark:text-zinc-900"
              exit={{ opacity: 0, x: 4 }}
              initial={{ opacity: 0, x: 4 }}
              transition={{ duration: 0.14, ease: EASE_OUT }}
              type="submit"
            >
              Create
            </motion.button>
          )}
        </AnimatePresence>
      </form>
    </div>
  );
}

Save Button

By Kokonut UI

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

About Save Button

Save 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/save-button.tsx

import SaveButton from "@/components/kokonutui/save-button";
export default function ImportedDemo(){return <>
    <div className="flex h-[26rem] items-start justify-center pt-16">
        <SaveButton />
    </div>
</>}

components/kokonutui/save-button.tsx

"use client";

/**
 * @author: @dorianbaffier
 * @description: Save Button
 * @version: 1.0.0
 * @date: 2026-10-21
 * @license: MIT
 * @website: https://kokonutui.com
 * @github: https://github.com/kokonut-labs/kokonutui
 */

import {Check,Plus} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  type FormEvent,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";

const EASE_OUT = [0.23, 1, 0.32, 1] as const;
/** How long the "Saved to" strip stays up when left alone. */
const STRIP_FOR = 4000;

export interface SaveCollection {
  id: string;
  name: string;
  /** Items already in it, before this one. */
  count: number;
  /** Tailwind background class for its swatch. */
  color: string;
}

export interface SaveButtonProps {
  collections?: SaveCollection[];
  /** Where a tap saves to, by id. Defaults to the first collection. */
  defaultCollection?: string;
  defaultSaved?: boolean;
  onSave?: (collectionId: string) => void;
  onMove?: (collectionId: string) => void;
  onRemove?: () => void;
  className?: string;
}

const DEFAULT_COLLECTIONS: SaveCollection[] = [
  { id: "weekend", name: "Weekend trips", count: 12, color: "bg-orange-400" },
  { id: "tokyo", name: "Tokyo 2027", count: 31, color: "bg-sky-400" },
  { id: "food", name: "Places to eat", count: 48, color: "bg-emerald-400" },
  { id: "someday", name: "Someday", count: 7, color: "bg-violet-400" },
];

const SWATCHES = [
  "bg-rose-400",
  "bg-amber-300",
  "bg-teal-400",
  "bg-indigo-400",
];

type View = "closed" | "saved" | "picking" | "removed";

// The panel sits under AnimatePresence, so it's found by query, not ref.
const findPanel = (root: HTMLElement | null) =>
  root?.querySelector("[data-save-panel]") ?? null;

const focusInPanel = (root: HTMLElement | null) =>
  Boolean(findPanel(root)?.contains(document.activeElement));

// Whatever closes or swaps the panel's content would drop focus on <body>,
// so it goes back to the button first.
const returnFocus = (root: HTMLElement | null, button: HTMLElement | null) => {
  if (focusInPanel(root)) {
    button?.focus();
  }
};

/** A bookmark whose ink drops in from the top like a ribbon when saved. */
function Ribbon({
  saved,
  reduceMotion,
}: {
  saved: boolean;
  reduceMotion: boolean;
}) {
  const clip = useId();
  let ribbonTransition: object = { duration: 0.18, ease: EASE_OUT };
  if (reduceMotion) {
    ribbonTransition = { duration: 0 };
  } else if (saved) {
    ribbonTransition = { type: "spring", stiffness: 520, damping: 24 };
  }
  return (
    <svg
      aria-hidden
      className="size-4 overflow-visible"
      fill="none"
      viewBox="0 0 16 16"
    >
      <title>Bookmark</title>
      <defs>
        <clipPath id={clip}>
          <path d="M3.5 2.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v11.2l-4.5-3-4.5 3z" />
        </clipPath>
      </defs>
      <g clipPath={`url(#${clip})`}>
        <motion.rect
          animate={{ y: saved ? 0 : -16 }}
          className="fill-current"
          height="16"
          initial={false}
          transition={ribbonTransition}
          width="16"
          x="0"
          y="0"
        />
      </g>
      <path
        className="stroke-current"
        d="M3.5 2.5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v11.2l-4.5-3-4.5 3z"
        strokeLinejoin="round"
        strokeWidth="1.5"
      />
    </svg>
  );
}

export default function SaveButton({
  collections: initialCollections = DEFAULT_COLLECTIONS,
  defaultCollection,
  defaultSaved = false,
  onSave,
  onMove,
  onRemove,
  className,
}: SaveButtonProps) {
  const [collections, setCollections] = useState(initialCollections);
  const [savedTo, setSavedTo] = useState<string | null>(
    defaultSaved
      ? (defaultCollection ?? initialCollections[0]?.id ?? null)
      : null
  );
  const [lastUsed, setLastUsed] = useState(
    defaultCollection ?? initialCollections[0]?.id ?? ""
  );
  const [view, setView] = useState<View>("closed");
  const [draft, setDraft] = useState("");
  const [moved, setMoved] = useState(false);
  const reduceMotion = Boolean(useReducedMotion());

  const rootRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const hoverRef = useRef(false);
  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

  const contentRef = useRef<HTMLDivElement>(null);
  const isOpen = view !== "closed";
  const [contentHeight, setContentHeight] = useState<number>();

  // The strip grows into the picker and back by its measured height.
  useLayoutEffect(() => {
    const content = contentRef.current;
    if (!(isOpen && content)) {
      setContentHeight(undefined);
      return;
    }
    const observer = new ResizeObserver(() =>
      setContentHeight(content.offsetHeight)
    );
    observer.observe(content);
    return () => observer.disconnect();
  }, [isOpen]);

  const saved = savedTo !== null;
  const current = collections.find((c) => c.id === savedTo);

  // The strip closes on its own unless it is being read or used.
  const schedule = (ms = STRIP_FOR) => {
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      if (!(hoverRef.current || focusInPanel(rootRef.current))) {
        setView((v) => (v === "picking" ? v : "closed"));
      }
    }, ms);
  };

  useEffect(() => () => clearTimeout(timerRef.current), []);

  useEffect(() => {
    if (view === "closed") {
      return;
    }
    const onPointer = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) {
        setView("closed");
      }
    };
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        returnFocus(rootRef.current, buttonRef.current);
        setView("closed");
      }
    };
    document.addEventListener("pointerdown", onPointer);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointer);
      document.removeEventListener("keydown", onKey);
    };
  }, [view]);

  // Opening the picker moves focus onto the collection it's saved in.
  useEffect(() => {
    if (view === "picking") {
      findPanel(rootRef.current)
        ?.querySelector<HTMLButtonElement>('[aria-current="true"]')
        ?.focus();
    }
  }, [view]);

  const toggle = () => {
    if (saved) {
      setSavedTo(null);
      setView("removed");
      onRemove?.();
      schedule(1600);
      return;
    }
    setSavedTo(lastUsed);
    setMoved(false);
    setView("saved");
    onSave?.(lastUsed);
    schedule();
  };

  const moveTo = (id: string) => {
    returnFocus(rootRef.current, buttonRef.current);
    setSavedTo(id);
    setLastUsed(id);
    setMoved(id !== savedTo);
    setView("saved");
    if (id !== savedTo) {
      onMove?.(id);
    }
    schedule();
  };

  const create = (event: FormEvent) => {
    event.preventDefault();
    const name = draft.trim();
    if (!name) {
      return;
    }
    const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${collections.length}`;
    const color = SWATCHES[collections.length % SWATCHES.length];
    setCollections((list) => [...list, { id, name, count: 0, color }]);
    setDraft("");
    moveTo(id);
  };

  const countFor = (collection: SaveCollection) =>
    collection.count + (savedTo === collection.id ? 1 : 0);

  const swap = reduceMotion
    ? { duration: 0 }
    : { duration: 0.2, ease: EASE_OUT };

  return (
    <div
      className={cn("relative inline-flex", className)}
      onPointerEnter={() => {
        hoverRef.current = true;
      }}
      onPointerLeave={() => {
        hoverRef.current = false;
        if (view === "saved" || view === "removed") {
          schedule(1800);
        }
      }}
      ref={rootRef}
    >
      <button
        aria-pressed={saved}
        className={cn(
          "inline-flex h-10 items-center gap-2 rounded-full border pr-4 pl-3.5 font-medium text-sm outline-none",
          "transition-colors duration-150 ease-out",
          "focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950",
          saved
            ? "border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800 dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
            : "border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-900"
        )}
        onClick={toggle}
        ref={buttonRef}
        type="button"
      >
        <Ribbon reduceMotion={reduceMotion} saved={saved} />
        <span className="relative inline-flex overflow-hidden">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: saved ? -14 : 14, opacity: 0 }}
              initial={{ y: saved ? 14 : -14, opacity: 0 }}
              key={saved ? "saved" : "save"}
              transition={swap}
            >
              {saved ? "Saved" : "Save"}
            </motion.span>
          </AnimatePresence>
        </span>
      </button>

      <AnimatePresence>
        {view !== "closed" && (
          <motion.div
            animate={{ opacity: 1, y: 0, scale: 1 }}
            className="absolute top-full left-1/2 z-20 mt-2 w-72 origin-top -translate-x-1/2 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-[0_8px_24px_-8px_rgb(0_0_0/0.6)]"
            data-save-panel
            exit={{
              opacity: 0,
              y: -4,
              scale: 0.98,
              transition: { duration: 0.12 },
            }}
            initial={{ opacity: 0, y: -6, scale: 0.97 }}
            transition={
              reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }
            }
          >
            <motion.div
              animate={{ height: contentHeight ?? "auto" }}
              initial={false}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.26, ease: EASE_OUT }
              }
            >
              <div ref={contentRef}>
                {view === "picking" ? (
                  <Picker
                    collections={collections}
                    countFor={countFor}
                    create={create}
                    draft={draft}
                    moveTo={moveTo}
                    savedTo={savedTo}
                    setDraft={setDraft}
                  />
                ) : (
                  <div
                    aria-live="polite"
                    className="flex h-12 items-center gap-2.5 pr-2 pl-3 text-sm"
                  >
                    {view === "removed" || !current ? (
                      <span className="text-zinc-500 dark:text-zinc-400">
                        Removed from saved
                      </span>
                    ) : (
                      <>
                        <span
                          className={cn(
                            "size-5 shrink-0 rounded-md",
                            current.color
                          )}
                        />
                        <span className="min-w-0 flex-1 truncate text-zinc-500 dark:text-zinc-400">
                          {moved ? "Moved to " : "Saved to "}
                          <span className="relative inline-flex align-bottom">
                            <AnimatePresence initial={false} mode="popLayout">
                              <motion.span
                                animate={{ y: 0, opacity: 1 }}
                                className="font-medium text-zinc-900 dark:text-zinc-100"
                                exit={{ y: -10, opacity: 0 }}
                                initial={{ y: 10, opacity: 0 }}
                                key={current.id}
                                transition={swap}
                              >
                                {current.name}
                              </motion.span>
                            </AnimatePresence>
                          </span>
                        </span>
                        <button
                          className="h-8 shrink-0 rounded-full px-3 font-medium text-xs text-zinc-900 transition-colors duration-150 hover:bg-zinc-100 dark:text-zinc-100 dark:hover:bg-zinc-900"
                          onClick={() => {
                            clearTimeout(timerRef.current);
                            setView("picking");
                          }}
                          type="button"
                        >
                          Change
                        </button>
                      </>
                    )}
                  </div>
                )}
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function Picker({
  collections,
  savedTo,
  countFor,
  moveTo,
  draft,
  setDraft,
  create,
}: {
  collections: SaveCollection[];
  savedTo: string | null;
  countFor: (collection: SaveCollection) => number;
  moveTo: (id: string) => void;
  draft: string;
  setDraft: (value: string) => void;
  create: (event: FormEvent) => void;
}) {
  return (
    <div className="p-1.5">
      <p className="px-2 pt-1.5 pb-1 text-[11px] text-zinc-400 dark:text-zinc-500">
        Save to
      </p>
      <ul className="max-h-56 overflow-y-auto">
        {collections.map((collection) => {
          const active = collection.id === savedTo;
          return (
            <li key={collection.id}>
              <button
                aria-current={active}
                className="flex h-11 w-full items-center gap-2.5 rounded-xl px-2 text-left text-sm outline-none transition-colors duration-150 hover:bg-zinc-100 focus-visible:bg-zinc-100 dark:focus-visible:bg-zinc-900 dark:hover:bg-zinc-900"
                onClick={() => moveTo(collection.id)}
                type="button"
              >
                <span
                  className={cn("size-7 shrink-0 rounded-lg", collection.color)}
                />
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-zinc-900 dark:text-zinc-100">
                    {collection.name}
                  </span>
                  <span className="block text-[11px] text-zinc-400 tabular-nums dark:text-zinc-500">
                    {countFor(collection)} saved
                  </span>
                </span>
                {active && (
                  <Check
                    aria-hidden
                    className="size-4 text-zinc-900 dark:text-zinc-100"
                    strokeWidth={2.5}
                  />
                )}
              </button>
            </li>
          );
        })}
      </ul>
      <form
        className="mt-1 flex h-11 items-center gap-2.5 rounded-xl border-zinc-100 border-t px-2 pt-1 dark:border-zinc-900"
        onSubmit={create}
      >
        <span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-zinc-300 border-dashed text-zinc-400 dark:border-zinc-700">
          <Plus aria-hidden className="size-3.5" />
        </span>
        <input
          aria-label="New collection"
          className="min-w-0 flex-1 bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
          onChange={(event) => setDraft(event.target.value)}
          placeholder="New collection"
          value={draft}
        />
        <AnimatePresence initial={false}>
          {draft.trim() && (
            <motion.button
              animate={{ opacity: 1, x: 0 }}
              className="h-7 shrink-0 rounded-full bg-zinc-900 px-2.5 font-medium text-[11px] text-white dark:bg-zinc-100 dark:text-zinc-900"
              exit={{ opacity: 0, x: 4 }}
              initial={{ opacity: 0, x: 4 }}
              transition={{ duration: 0.14, ease: EASE_OUT }}
              type="submit"
            >
              Create
            </motion.button>
          )}
        </AnimatePresence>
      </form>
    </div>
  );
}

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/save-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-orange-400: oklch(75% 0.183 55.934);
    --color-amber-300: oklch(87.9% 0.169 91.605);
    --color-emerald-400: oklch(76.5% 0.177 163.223);
    --color-teal-400: oklch(77.7% 0.152 181.912);
    --color-sky-400: oklch(74.6% 0.16 232.661);
    --color-indigo-400: oklch(67.3% 0.182 276.935);
    --color-violet-400: oklch(70.2% 0.183 293.541);
    --color-rose-400: oklch(71.2% 0.194 13.428);
    --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-xs: 0.75rem;
    --text-xs--line-height: calc(1 / 0.75);
    --text-sm: 0.875rem;
    --text-sm--line-height: calc(1.25 / 0.875);
    --font-weight-medium: 500;
    --radius-md: calc(var(--radius) - 2px);
    --radius-lg: var(--radius);
    --radius-2xl: 1rem;
    --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 {
  .absolute {
    position: absolute;
  }
  .relative {
    position: relative;
  }
  .top-full {
    top: 100%;
  }
  .left-1\/2 {
    left: calc(1 / 2 * 100%);
  }
  .z-20 {
    z-index: 20;
  }
  .mt-1 {
    margin-top: var(--spacing);
  }
  .mt-2 {
    margin-top: calc(var(--spacing) * 2);
  }
  .block {
    display: block;
  }
  .flex {
    display: flex;
  }
  .inline-flex {
    display: inline-flex;
  }
  .size-3\.5 {
    width: calc(var(--spacing) * 3.5);
    height: calc(var(--spacing) * 3.5);
  }
  .size-4 {
    width: calc(var(--spacing) * 4);
    height: calc(var(--spacing) * 4);
  }
  .size-5 {
    width: calc(var(--spacing) * 5);
    height: calc(var(--spacing) * 5);
  }
  .size-7 {
    width: calc(var(--spacing) * 7);
    height: calc(var(--spacing) * 7);
  }
  .h-7 {
    height: calc(var(--spacing) * 7);
  }
  .h-8 {
    height: calc(var(--spacing) * 8);
  }
  .h-10 {
    height: calc(var(--spacing) * 10);
  }
  .h-11 {
    height: calc(var(--spacing) * 11);
  }
  .h-12 {
    height: calc(var(--spacing) * 12);
  }
  .h-\[26rem\] {
    height: 26rem;
  }
  .max-h-56 {
    max-height: calc(var(--spacing) * 56);
  }
  .w-72 {
    width: calc(var(--spacing) * 72);
  }
  .w-full {
    width: 100%;
  }
  .min-w-0 {
    min-width: 0px;
  }
  .flex-1 {
    flex: 1;
  }
  .shrink-0 {
    flex-shrink: 0;
  }
  .origin-top {
    transform-origin: top;
  }
  .-translate-x-1\/2 {
    --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .items-center {
    align-items: center;
  }
  .items-start {
    align-items: flex-start;
  }
  .justify-center {
    justify-content: center;
  }
  .gap-2 {
    gap: calc(var(--spacing) * 2);
  }
  .gap-2\.5 {
    gap: calc(var(--spacing) * 2.5);
  }
  .truncate {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .overflow-hidden {
    overflow: hidden;
  }
  .overflow-visible {
    overflow: visible;
  }
  .overflow-y-auto {
    overflow-y: auto;
  }
  .rounded-2xl {
    border-radius: var(--radius-2xl);
  }
  .rounded-full {
    border-radius: calc(infinity * 1px);
  }
  .rounded-lg {
    border-radius: var(--radius-lg);
  }
  .rounded-md {
    border-radius: var(--radius-md);
  }
  .rounded-xl {
    border-radius: calc(var(--radius) + 4px);
  }
  .border {
    border-style: var(--tw-border-style);
    border-width: 1px;
  }
  .border-t {
    border-top-style: var(--tw-border-style);
    border-top-width: 1px;
  }
  .border-dashed {
    --tw-border-style: dashed;
    border-style: dashed;
  }
  .border-zinc-100 {
    border-color: var(--color-zinc-100);
  }
  .border-zinc-200 {
    border-color: var(--color-zinc-200);
  }
  .border-zinc-300 {
    border-color: var(--color-zinc-300);
  }
  .border-zinc-900 {
    border-color: var(--color-zinc-900);
  }
  .bg-amber-300 {
    background-color: var(--color-amber-300);
  }
  .bg-emerald-400 {
    background-color: var(--color-emerald-400);
  }
  .bg-indigo-400 {
    background-color: var(--color-indigo-400);
  }
  .bg-orange-400 {
    background-color: var(--color-orange-400);
  }
  .bg-rose-400 {
    background-color: var(--color-rose-400);
  }
  .bg-sky-400 {
    background-color: var(--color-sky-400);
  }
  .bg-teal-400 {
    background-color: var(--color-teal-400);
  }
  .bg-transparent {
    background-color: transparent;
  }
  .bg-violet-400 {
    background-color: var(--color-violet-400);
  }
  .bg-white {
    background-color: var(--color-white);
  }
  .bg-zinc-900 {
    background-color: var(--color-zinc-900);
  }
  .fill-current {
    fill: currentcolor;
  }
  .stroke-current {
    stroke: currentcolor;
  }
  .p-1\.5 {
    padding: calc(var(--spacing) * 1.5);
  }
  .px-2 {
    padding-inline: calc(var(--spacing) * 2);
  }
  .px-2\.5 {
    padding-inline: calc(var(--spacing) * 2.5);
  }
  .px-3 {
    padding-inline: calc(var(--spacing) * 3);
  }
  .pt-1 {
    padding-top: var(--spacing);
  }
  .pt-1\.5 {
    padding-top: calc(var(--spacing) * 1.5);
  }
  .pt-16 {
    padding-top: calc(var(--spacing) * 16);
  }
  .pr-2 {
    padding-right: calc(var(--spacing) * 2);
  }
  .pr-4 {
    padding-right: calc(var(--spacing) * 4);
  }
  .pb-1 {
    padding-bottom: var(--spacing);
  }
  .pl-3 {
    padding-left: calc(var(--spacing) * 3);
  }
  .pl-3\.5 {
    padding-left: calc(var(--spacing) * 3.5);
  }
  .text-left {
    text-align: left;
  }
  .align-bottom {
    vertical-align: bottom;
  }
  .text-sm {
    font-size: var(--text-sm);
    line-height: var(--tw-leading, var(--text-sm--line-height));
  }
  .text-xs {
    font-size: var(--text-xs);
    line-height: var(--tw-leading, var(--text-xs--line-height));
  }
  .text-\[11px\] {
    font-size: 11px;
  }
  .font-medium {
    --tw-font-weight: var(--font-weight-medium);
    font-weight: var(--font-weight-medium);
  }
  .text-white {
    color: var(--color-white);
  }
  .text-zinc-400 {
    color: var(--color-zinc-400);
  }
  .text-zinc-500 {
    color: var(--color-zinc-500);
  }
  .text-zinc-900 {
    color: var(--color-zinc-900);
  }
  .tabular-nums {
    --tw-numeric-spacing: tabular-nums;
    font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
  }
  .shadow-\[0_8px_24px_-8px_rgb\(0_0_0\/0\.18\)\] {
    --tw-shadow: 0 8px 24px -8px var(--tw-shadow-color, rgb(0 0 0/0.18));
    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-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-colors {
    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
    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;
  }
  .placeholder\:text-zinc-400::placeholder {
    color: var(--color-zinc-400);
  }
  @media (hover: hover) {
    .hover\:bg-zinc-50:hover {
      background-color: var(--color-zinc-50);
    }
    .hover\:bg-zinc-100:hover {
      background-color: var(--color-zinc-100);
    }
    .hover\:bg-zinc-800:hover {
      background-color: var(--color-zinc-800);
    }
  }
  .focus-visible\:bg-zinc-100:focus-visible {
    background-color: var(--color-zinc-100);
  }
  .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);
  }
  .dark\:border-zinc-100:where(.dark, .dark *) {
    border-color: var(--color-zinc-100);
  }
  .dark\:border-zinc-700:where(.dark, .dark *) {
    border-color: var(--color-zinc-700);
  }
  .dark\:border-zinc-800:where(.dark, .dark *) {
    border-color: var(--color-zinc-800);
  }
  .dark\:border-zinc-900:where(.dark, .dark *) {
    border-color: var(--color-zinc-900);
  }
  .dark\:bg-zinc-100:where(.dark, .dark *) {
    background-color: var(--color-zinc-100);
  }
  .dark\:bg-zinc-950:where(.dark, .dark *) {
    background-color: var(--color-zinc-950);
  }
  .dark\:text-zinc-100:where(.dark, .dark *) {
    color: var(--color-zinc-100);
  }
  .dark\:text-zinc-400:where(.dark, .dark *) {
    color: var(--color-zinc-400);
  }
  .dark\:text-zinc-500:where(.dark, .dark *) {
    color: var(--color-zinc-500);
  }
  .dark\:text-zinc-900:where(.dark, .dark *) {
    color: var(--color-zinc-900);
  }
  .dark\:shadow-\[0_8px_24px_-8px_rgb\(0_0_0\/0\.6\)\]:where(.dark, .dark *) {
    --tw-shadow: 0 8px 24px -8px var(--tw-shadow-color, rgb(0 0 0/0.6));
    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
  }
  .dark\:placeholder\:text-zinc-500:where(.dark, .dark *)::placeholder {
    color: var(--color-zinc-500);
  }
  @media (hover: hover) {
    .dark\:hover\:bg-white:where(.dark, .dark *):hover {
      background-color: var(--color-white);
    }
    .dark\:hover\:bg-zinc-900:where(.dark, .dark *):hover {
      background-color: var(--color-zinc-900);
    }
  }
  .dark\:focus-visible\:bg-zinc-900:where(.dark, .dark *):focus-visible {
    background-color: var(--color-zinc-900);
  }
  .dark\:focus-visible\:ring-offset-zinc-950:where(.dark, .dark *):focus-visible {
    --tw-ring-offset-color: var(--color-zinc-950);
  }
}
: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-font-weight {
  syntax: "*";
  inherits: false;
}
@property --tw-ordinal {
  syntax: "*";
  inherits: false;
}
@property --tw-slashed-zero {
  syntax: "*";
  inherits: false;
}
@property --tw-numeric-figure {
  syntax: "*";
  inherits: false;
}
@property --tw-numeric-spacing {
  syntax: "*";
  inherits: false;
}
@property --tw-numeric-fraction {
  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-duration {
  syntax: "*";
  inherits: false;
}
@property --tw-ease {
  syntax: "*";
  inherits: false;
}
@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-font-weight: initial;
      --tw-ordinal: initial;
      --tw-slashed-zero: initial;
      --tw-numeric-figure: initial;
      --tw-numeric-spacing: initial;
      --tw-numeric-fraction: 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-duration: initial;
      --tw-ease: initial;
    }
  }
}