All patterns

Bookmark Save

The mark fills from the notch up, tucks into its corner, and a ribbon slides down behind the card edge.

socialsubtlepremiuminteraction · finite · starter · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

312 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Bookmark Save
 *
 * The save that reads as filing something away: the mark fills from the
 * notch up, settles a couple of pixels deeper into its corner, and a
 * ribbon tucks itself behind the top edge of the card.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `saved`, `onSavedChange`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BookmarkSaveTuckProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Controlled state. Omit it and the component owns the toggle. */
  saved?: boolean;
  /** Starting state when uncontrolled. */
  defaultSaved?: boolean;
  /** Called with the next state on every toggle. */
  onSavedChange?: (saved: boolean) => void;
  title?: string;
  source?: string;
  /** Where a saved item lands — shown once it is saved. */
  collection?: string;
  /** Saved-state color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Seconds for the fill to climb the mark. */
  fillSeconds: number;
  /** px the mark settles down into its corner once filled. */
  tuck: number;
  /** px of ribbon left showing below the card's top edge. */
  ribbon: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: every spring here sits at or above a 0.8 damping ratio
// (damping / 2√stiffness). A bookmark that springs past its rest position
// looks like it failed to save; the only thing allowed to move twice is
// the fill climbing the mark. Variants differ in speed and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a tuck. For a dense results list where saves are frequent.
  subtle: {
    fillSeconds: 0.15,
    tuck: 1,
    ribbon: 5,
    spring: { type: "spring", stiffness: 670, damping: 48 },
  },
  // Enough travel to feel the item get filed. The all-purpose setting.
  default: {
    fillSeconds: 0.24,
    tuck: 2,
    ribbon: 9,
    spring: { type: "spring", stiffness: 520, damping: 40 },
  },
  // A longer climb and a deeper tuck, for a single featured card.
  playful: {
    fillSeconds: 0.33,
    tuck: 3,
    ribbon: 13,
    spring: { type: "spring", stiffness: 380, damping: 32 },
  },
};

const ACCENT = "#4C7DF0";

/** Theme-adaptive neutral: `currentColor` is the inherited text color —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields surfaces and borders correctly toned in
 *  either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const MARK = "M4 3.4h12a1.6 1.6 0 0 1 1.6 1.6v15.6L10 16.4 2.4 20.6V5a1.6 1.6 0 0 1 1.6-1.6Z";
const RIBBON_HEIGHT = 34;

export default function BookmarkSaveTuck({
  variant = "default",
  saved,
  defaultSaved = false,
  onSavedChange,
  title = "The quiet economics of small teams",
  source = "Field Notes · 7 min read",
  collection = "Read later",
  accent = ACCENT,
}: BookmarkSaveTuckProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [ownSaved, setOwnSaved] = useState(defaultSaved);
  const isSaved = saved ?? ownSaved;

  const toggle = () => {
    const next = !isSaved;
    if (saved === undefined) setOwnSaved(next);
    onSavedChange?.(next);
  };

  // Reduced motion: the fill and the ribbon arrive at their saved
  // positions without travelling. The state is carried by color, by the
  // ribbon being there at all, and by the label — never by movement alone.
  const settle = reduceMotion ? { duration: 0 } : cfg.spring;
  const fill = reduceMotion
    ? { duration: 0 }
    : { duration: cfg.fillSeconds, ease: [0.32, 0.72, 0, 1] as const };

  return (
    <div
      style={{
        position: "relative",
        width: 320,
        overflow: "hidden",
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      {/* The ribbon lives above the card's top edge and is pulled down
          into view, so the saved state reads from across the page even
          when the mark itself is off-screen in a long list. */}
      <motion.span
        aria-hidden
        initial={false}
        animate={{ y: isSaved ? cfg.ribbon - RIBBON_HEIGHT : -RIBBON_HEIGHT - 2 }}
        transition={settle}
        style={{
          position: "absolute",
          top: 0,
          right: 20,
          width: 15,
          height: RIBBON_HEIGHT,
          background: accent,
          opacity: 0.9,
          clipPath: "polygon(0 0, 100% 0, 100% 100%, 50% 74%, 0 100%)",
        }}
      />

      <div
        style={{
          display: "flex",
          alignItems: "flex-start",
          gap: 12,
          padding: "16px 16px 13px",
        }}
      >
        <span style={{ flex: 1, minWidth: 0 }}>
          <span
            style={{
              display: "block",
              fontSize: 14,
              fontWeight: 600,
              lineHeight: 1.35,
            }}
          >
            {title}
          </span>
          <span
            style={{
              display: "flex",
              alignItems: "center",
              gap: 7,
              marginTop: 5,
              fontSize: 11.5,
              opacity: 0.55,
            }}
          >
            {source}
            {/* The collection chip only exists once the item is filed, so
                it fades in place rather than swapping something out. */}
            <AnimatePresence initial={false}>
              {isSaved && (
                <motion.span
                  key="collection"
                  initial={{ opacity: 0, x: reduceMotion ? 0 : -4 }}
                  animate={{ opacity: 1, x: 0 }}
                  exit={{ opacity: 0, x: reduceMotion ? 0 : -4 }}
                  transition={{ duration: 0.2, ease: "easeOut" }}
                  style={{
                    padding: "2px 7px",
                    borderRadius: 999,
                    fontSize: 10.5,
                    fontWeight: 600,
                    color: accent,
                    background: `color-mix(in srgb, ${accent} 14%, transparent)`,
                    whiteSpace: "nowrap",
                  }}
                >
                  {collection}
                </motion.span>
              )}
            </AnimatePresence>
          </span>
        </span>

        <button
          type="button"
          onClick={toggle}
          aria-pressed={isSaved}
          aria-label={isSaved ? "Remove from saved items" : "Save this item"}
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            flexShrink: 0,
            padding: "5px 9px 5px 8px",
            borderRadius: 10,
            border: `1px solid ${isSaved ? "transparent" : tone(12)}`,
            background: isSaved ? `color-mix(in srgb, ${accent} 12%, transparent)` : tone(4),
            color: "inherit",
            fontFamily: "inherit",
            cursor: "pointer",
          }}
        >
          <motion.span
            aria-hidden
            initial={false}
            animate={{ y: isSaved ? cfg.tuck : 0 }}
            transition={settle}
            style={{ position: "relative", display: "block", width: 20, height: 24 }}
          >
            <svg
              width="20"
              height="24"
              viewBox="0 0 20 24"
              fill="none"
              style={{ position: "absolute", inset: 0, display: "block" }}
            >
              <path
                d={MARK}
                stroke="currentColor"
                strokeWidth="1.7"
                strokeLinejoin="round"
                opacity={isSaved ? 0 : 0.55}
              />
            </svg>
            {/* Bottom-anchored clip: the filled mark is laid out at full
                size from frame one and uncovered from the notch upward,
                so nothing about the glyph is ever scaled or redrawn. */}
            <motion.span
              initial={false}
              animate={{ height: isSaved ? 24 : 0 }}
              transition={fill}
              style={{
                position: "absolute",
                left: 0,
                bottom: 0,
                width: 20,
                overflow: "hidden",
              }}
            >
              <svg
                width="20"
                height="24"
                viewBox="0 0 20 24"
                fill="none"
                style={{ position: "absolute", left: 0, bottom: 0, display: "block" }}
              >
                <path
                  d={MARK}
                  fill={accent}
                  stroke={accent}
                  strokeWidth="1.7"
                  strokeLinejoin="round"
                />
              </svg>
            </motion.span>
          </motion.span>

          {/* Fixed-width slot: the wording changes, the type never moves
              sideways to make room for a longer word. */}
          <span
            style={{
              position: "relative",
              width: 40,
              height: 15,
              fontSize: 12,
              fontWeight: 600,
            }}
          >
            <AnimatePresence initial={false}>
              <motion.span
                key={isSaved ? "saved" : "save"}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.16, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: 0,
                  lineHeight: "15px",
                  textAlign: "left",
                  color: isSaved ? accent : "inherit",
                  opacity: isSaved ? 1 : 0.75,
                }}
              >
                {isSaved ? "Saved" : "Save"}
              </motion.span>
            </AnimatePresence>
          </span>
        </button>
      </div>
    </div>
  );
}

About this pattern

Saving something is a filing action, not a celebration, so the motion is about the item ending up somewhere rather than about the tap being applauded. The fill climbs the mark from the notch upward behind a bottom-anchored clip — the glyph is laid out at full size from the first frame and simply uncovered, so no path is ever redrawn or scaled — then the mark settles two pixels deeper, as if it had been pressed into the corner it lives in. A ribbon pulled down from above the card's top edge does the work the icon can't: it is visible from across a long list, which is where saved items are actually recognised. Unsaving reverses every step at the same speed, because a save you can't confidently undo is a save people stop making.

Save an articleReading listBookmark a listingFile a post into a collection

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Marcus Bell5hNew supplier signed. Same rate, twelve more months.
    423
    Social feed

    The save mark under a post fills on tap and offers the collection the post landed in.

Related patterns