Buttons

Bookmark Button

Save toggle with a scale-settle pop and single ring burst on save, a quiet fade on unsave, and an optimistic digit-roll count.

Install

npx shadcn@latest add @paragon/bookmark-button

Also installs: digit-roll

bookmark-button.tsx

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