Save toggle with a scale-settle pop and single ring burst on save, a quiet fade on unsave, and an optimistic digit-roll count.
npx shadcn@latest add @paragon/bookmark-buttonAlso installs: digit-roll
"use client";
import * as React from "react";
import { Bookmark } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
export interface BookmarkButtonProps extends React.ComponentProps<"button"> {
/** Controlled saved state. Leave undefined for uncontrolled. */
saved?: boolean;
/** Initial state when uncontrolled. */
defaultSaved?: boolean;
/** Fires with the next state on every toggle. */
onSavedChange?: (saved: boolean) => void;
/** Save count excluding the current user; +1 is applied optimistically. */
count?: number;
/** Disables the pop and burst; state still swaps. */
static?: boolean;
}
/**
* Save/bookmark toggle. Saving fills the icon with a scale-settle pop
* (1 -> 1.15 -> 1 on --ease-bounce) and fires a single ring burst; unsaving
* is a quiet 150ms fade back — celebration is for commitment, not retreat.
* The optimistic count ticks with a digit roll. The burst layer is keyed per
* save, so rapid toggling restarts cleanly.
*/
export function BookmarkButton({
saved: controlledSaved,
defaultSaved = false,
onSavedChange,
count,
static: isStatic = false,
className,
onClick,
...props
}: BookmarkButtonProps) {
const reducedMotion = useReducedMotion();
const [uncontrolledSaved, setUncontrolledSaved] = React.useState(defaultSaved);
const isControlled = controlledSaved !== undefined;
const saved = isControlled ? controlledSaved : uncontrolledSaved;
// Incremented on each save; keys the pop + burst so they restart.
const [saveTick, setSaveTick] = React.useState(0);
const animate = !isStatic && !reducedMotion;
return (
<>
<style href="paragon-bookmark-button" precedence="paragon">{`
@keyframes pg-bookmark-pop {
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
@keyframes pg-bookmark-burst {
0% { transform: scale(0.4); opacity: 0.5; }
100% { transform: scale(1.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-bookmark] * { animation: none !important; }
}
`}</style>
<button
type="button"
data-bookmark=""
data-slot="bookmark-button"
aria-pressed={saved}
aria-label={saved ? "Remove bookmark" : "Bookmark"}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
const next = !saved;
if (!isControlled) setUncontrolledSaved(next);
if (next) setSaveTick((tick) => tick + 1);
onSavedChange?.(next);
}}
className={cn(
"pressable relative inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-[13px] font-medium text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-foreground",
"after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
saved && "text-foreground",
className,
)}
{...props}
>
<span className="relative flex size-4 items-center justify-center">
{saveTick > 0 && animate && (
<span
key={saveTick}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full border border-current"
style={{
animation: `pg-bookmark-burst 400ms var(--ease-out) forwards`,
}}
/>
)}
<span
key={animate ? saveTick : 0}
className="flex"
style={
saveTick > 0 && animate
? { animation: `pg-bookmark-pop 300ms var(--ease-bounce)` }
: undefined
}
>
<Bookmark
aria-hidden
className={cn(
"size-4 transition-[fill] duration-150 ease-out",
saved ? "fill-current" : "fill-transparent",
)}
/>
</span>
</span>
{typeof count === "number" && (
<DigitRoll
value={count + (saved ? 1 : 0)}
static={isStatic}
className="min-w-[1ch]"
/>
)}
</button>
</>
);
}