"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;
}
}
}