GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Bookmark Icon Animation: 9 Patterns, One Icon

bookmark icon animation: Saved. One word, nine feelings — 03 overshoots by a spring value, 06 stacks the count digit by digit, and 09 snaps onto the list by a

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine Bookmark Icon Animations Ordered by How Much of the Icon Actually Moves

Every pattern below still ends at the same two states — saved or not — but each one reaches that state through a different single number read every frame or every event. The order below runs roughly from decoration to mechanism: 01 only reacts to a pointer that's already hovering, 02 and 03 read a single tap and either morph or overshoot, 04 and 05 add a physical read (a fold, a rising liquid level) on top of the same tap, 06 and 07 turn one save into a second visible change elsewhere on the page, and 08 and 09 hand the driving value to a longer gesture — a long press or a drag — instead of a single click. Most of the bookmark ui design patterns a reader has actually used only cover the first three or four of these; the last five are closer to what a real saved-items feature needs once a list, a count, or a folder enters the picture.

01Bookmark Icon That Tilts and Glows on Hover

The icon only moves while a real pointer sits over it: applyVector() turns the cursor's offset from the button's center into a clamped angle, and that one angle drives both a transform tilt and a conic-gradient glow position at once, so the light always sweeps toward wherever the cursor actually is. It never touches the saved state itself — it's the layer that makes a static save icon on an article list card feel like it's tracking the reader before they've clicked anything.

Bookmark Icon That Tilts and Glows on Hover
Open in a new tab

01 · Bookmark Icon That Tilts and Glows on Hover — Measured area 17.316% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

TILT_MAX_DEGGLOW_RADIUS_PXapplyVector
  // one value: pointer vector (dx,dy) → angle. that angle drives both the
  // tilt (rotateX/rotateY) and the glow-sweep position (conic-gradient angle) at once.
  function applyVector(dx, dy) {
    var dist = Math.hypot(dx, dy);
    var strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
    var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
    var tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
    var tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
    btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
    glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
    glow.style.setProperty("--glow-strength", strength.toFixed(3));
  }

Measured: 17.3% cumulative area, 24.3 average intensity, 21/23 frames moved.

02Bookmark Button That Morphs From Outline to Filled

Before a tap the ribbon is drawn as an outline only; setSaved() flips one boolean and render(p) reads the resulting progress value to grow a fill rect's height from the bottom up, so the ribbon never swaps icons — it fills itself in. That single fill percentage is what a wishlist button on a product card needs: a state change a shopper can watch happen instead of a hard cut between two SVGs.

Bookmark Button That Morphs From Outline to Filled
Open in a new tab

02 · Bookmark Button That Morphs From Outline to Filled — Measured area 16.04% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

CYCLE_SrendersetSaved
  function render(p) {
    // one value: fill progress (p, 0-1) → the SVG rect's y/height (clipped into a fill percentage)
    var y = 24 * (1 - p);
    fillRect.setAttribute("y", y.toFixed(3));
    fillRect.setAttribute("height", (24 - y).toFixed(3));
    panel.style.setProperty("--fill-p", p.toFixed(3));
  }

  function setSaved(v) {
    saved = v;
    target = v ? 1 : 0;
    btn.setAttribute("aria-pressed", String(v));
    status.textContent = v ? "Saved" : "Not saved";
  }

Measured: 16.0% cumulative area, 37.6 average intensity, 7/23 frames moved.

03Bookmark Button That Pops With a Spring Overshoot

A tap doesn't just grow the icon to its final size — integrate() runs an actual spring equation every frame, so the scale sails past 1 and gets pulled back by its own damping instead of easing straight to a target. That overshoot is what makes a save button next to a comment's like button read as a reaction rather than a settings toggle.

Bookmark Button That Pops With a Spring Overshoot
Open in a new tab

03 · Bookmark Button That Pops With a Spring Overshoot — Measured area 9.194% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

BOOKMARK_SPRING_OVERSHOOT_PERCENTSPRING_STIFFNESSintegrate
  // integrates the spring equation (stiffness SPRING_STIFFNESS, damping SPRING_DAMPING) every frame.
  // kicking the velocity once on press sends the spring past its target (1) before it settles back.
  function integrate(dt) {
    var accel = SPRING_STIFFNESS * (1 - pos) - SPRING_DAMPING * vel;
    vel += accel * dt;
    pos += vel * dt;
    if (pos > maxScale) { pos = maxScale; vel *= -0.35; }
    if (pos < 1 && vel < 0 && Math.abs(pos - 1) < 0.001 && Math.abs(vel) < 0.01) { pos = 1; vel = 0; }
  }

Measured: 9.2% cumulative area, 28.0 average intensity, 12/23 frames moved.

04Bookmark Ribbon That Folds and Drops

The same tap that saves the icon also runs a fold: render(p) reads one progress value along a sine curve and turns it into a rotateX fold angle, a small translateY drop, and a shadow whose blur and opacity both deepen as the ribbon falls lower. It's built for a save button at the top of an article detail page, where there's enough room for the fold and the shadow underneath it to actually read.

Bookmark Ribbon That Folds and Drops
Open in a new tab

04 · Bookmark Ribbon That Folds and Drops — Measured area 10.631% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLD_MAX_DEGFOLD_DROP_MAX_PXtriggerFold
  // one value: fold progress p (0-1, one sine-curve peak) → fold angle (rotateX) + drop distance
  // + shadow blur/opacity (the shadow deepens as the fold drops lower).
  function render(p) {
    scene.style.setProperty("--tone", p.toFixed(3));
    var deg = p * FOLD_MAX_DEG;
    var drop = p * FOLD_DROP_MAX_PX;
    wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
    var blur = 3 + p * 10;
    var spread = 6 - p * 4;
    var alpha = 0.22 + p * 0.28;
    shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
    shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
  }

Measured: 10.6% cumulative area, 64.4 average intensity, 17/23 frames moved.

05Bookmark Button Filled by a Rising Liquid Wave

waveD() rebuilds an SVG path from scratch every frame from two numbers — a water level and a time phase — so the surface isn't a static fill, it's a sine curve redrawn 60 times a second while the level climbs toward its target. That constant recompute is exactly what a save button on a video player can afford, since the button already sits over a screen that's redrawing itself anyway.

Bookmark Button Filled by a Rising Liquid Wave
Open in a new tab

05 · Bookmark Button Filled by a Rising Liquid Wave — Measured area 10.059% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

WAVE_AMPLITUDEWAVE_FREQUENCYwaveD
  // one value: water level (0-1) plus a time phase → a sine-wave path recalculated every frame.
  function waveD(lv, phase) {
    var baseY = 24 * (1 - lv);
    var d = "M0," + (baseY + WAVE_AMPLITUDE * Math.sin(phase)).toFixed(2) + " ";
    for (var x = 0; x <= 24; x += 2) {
      var y = baseY + WAVE_AMPLITUDE * Math.sin(x * WAVE_FREQUENCY * 0.26 + phase);
      d += "L" + x + "," + y.toFixed(2) + " ";
    }
    d += "L24,24 L0,24 Z";
    return d;
  }

Measured: 10.1% cumulative area, 68.8 average intensity, 11/23 frames moved.

06Save-Count Badge That Rolls Up Digit by Digit

Saving doesn't just flip the icon — it rolls a two-digit counter badge, and beginRoll() reads one interval value to decide how long each column waits before it starts: the rightmost digit goes first, the tens digit follows one interval later. That staggered roll is aimed at a counter badge that shows how many times a post was saved, where a hard-cut number change would read as a glitch rather than a count going up.

Save-Count Badge That Rolls Up Digit by Digit
Open in a new tab

06 · Save-Count Badge That Rolls Up Digit by Digit — Measured area 12.285% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

BOOKMARK_COUNT_STAGGER_MSDIGIT_ROLL_MSbeginRoll
  // one value: digit index (from the right) × BOOKMARK_COUNT_STAGGER_MS = the delay before that column starts rolling.
  var columns = [
    { delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
    { delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" }
  ];

  function pad2(n) { return (n < 10 ? "0" : "") + n; }

  function beginRoll(now) {
    var next = saved ? count + 1 : count - 1;
    var fromStr = pad2(count);
    var toStr = pad2(next);
    count = next;
    for (var i = 0; i < 2; i++) {
      columns[i].from = fromStr[i];
      columns[i].to = toStr[i];
      columns[i].start = now + columns[i].delay;
    }
    status.textContent = saved ? "Saved" : "Not saved";
    row.classList.add("cb__row--pulse");
    window.setTimeout(function () { row.classList.remove("cb__row--pulse"); }, 320);
  }

Measured: 12.3% cumulative area, 69.5 average intensity, 12/23 frames moved.

07Toast That Slides In to Confirm the Save

The icon itself barely moves here — render(now) reads how much time has passed since showToast() fired and turns that single elapsed value into which of three phases (enter, hold, exit) a bottom-of-screen notice band is in, plus how far along that phase is. It's built for the save-complete notice on a mobile app screen, where the icon's own state change is small enough that a toast is what actually confirms the save happened.

Toast That Slides In to Confirm the Save
Open in a new tab

07 · Toast That Slides In to Confirm the Save — Measured area 10.493% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

TOAST_ENTER_MSTOAST_HOLD_MSshowToast
  function showToast(now, msg) {
    toastText.textContent = msg;
    toastStart = now;
  }

  // one value: elapsed time since the toast appeared (accumulated rAF dt) → which of the
  // enter/hold/exit phases it's in, and how far along that phase is.
  function render(now) {
    if (toastStart === null) {
      toast.style.transform = "translateY(120%)";
      toast.style.opacity = "0";
      return;
    }
    var elapsed = now - toastStart;
    var enterMs = reduce ? 1 : TOAST_ENTER_MS;
    var exitMs = reduce ? 1 : TOAST_EXIT_MS;
    var enterEnd = enterMs;
    var holdEnd = enterEnd + TOAST_HOLD_MS;
    var exitEnd = holdEnd + exitMs;

Measured: 10.5% cumulative area, 109.0 average intensity, 11/23 frames moved.

08Popover for Picking Which Collection to Save Into

A long press, not a tap, is the trigger: once the hold clears LONGPRESS_MS, renderPanel() reads one item index times a stagger value to unfold a small list of collection names one after another, and picking one folds the panel shut again. Built for a folder-based save button on a recipe site, where "saved" isn't enough on its own — the reader also needs to say which list it went into.

Popover for Picking Which Collection to Save Into
Open in a new tab

08 · Popover for Picking Which Collection to Save Into — Measured area 31.662% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

LONGPRESS_MSPOPOVER_STAGGER_MSselectCollection
  // one value: list item index × POPOVER_STAGGER_MS = the delay before that item starts opening.
  function renderPanel(now) {
    if (openStart === null && !open) {
      items.forEach(function (el) { el.style.opacity = "0"; el.style.transform = "translateY(6px)"; });
      return;
    }
    var elapsed = openStart === null ? POPOVER_ITEM_MS * 3 : now - openStart;
    items.forEach(function (el, i) {
      var delay = i * POPOVER_STAGGER_MS;
      var raw = (elapsed - delay) / POPOVER_ITEM_MS;
      var p = Math.max(0, Math.min(1, reduce ? 1 : raw));
      var eased = 1 - Math.pow(1 - p, 3);
      el.style.opacity = eased.toFixed(3);
      el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
    });
  }

Measured: 31.7% cumulative area, 79.1 average intensity, 6/23 frames moved.

09Bookmark You Drag and Drop Into a Reading List

Dragging the chip is the only input release() cares about: it compares the distance between where the pointer let go and the drop zone against one snap-distance constant, and that single comparison decides whether the chip springs into the list or bounces back to where it started. It's aimed at organizing a reading list on a bookmark manager screen, where reordering by drag is the point rather than a single save toggle.

Bookmark You Drag and Drop Into a Reading List
Open in a new tab

09 · Bookmark You Drag and Drop Into a Reading List — Measured area 14.492% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

BOOKMARK_DRAG_SNAP_PXDRAG_INERTIA_DECAYrelease
  // one value: distance between the drop point and the drop zone vs BOOKMARK_DRAG_SNAP_PX →
  // snaps into the list or springs back to start.
  function release() {
    var dx = chipX - dropX, dy = chipY - dropY;
    var dist = Math.sqrt(dx * dx + dy * dy);
    if (dist <= BOOKMARK_DRAG_SNAP_PX) {
      targetX = dropX; targetY = dropY;
      setSaved(true);
    } else {
      targetX = 0; targetY = 0;
      setSaved(false);
    }
  }

Measured: 14.5% cumulative area, 43.1 average intensity, 19/23 frames moved.

Where the Value Drives the Save Motion — Spring Overshoot Ratio, Digit Stagger, Drag Snap Distance

Item Cumulative area Avg. intensity Moved frames
01 Hover tilt and glow 17.3% 24.3 21/23
02 Outline-to-fill morph 16.0% 37.6 7/23
03 Spring pop scale 9.2% 28.0 12/23
04 Ribbon fold and drop 10.6% 64.4 17/23
05 Liquid fill wave 10.1% 68.8 11/23
06 Stacked count badge 12.3% 69.5 12/23
07 Toast confirm slide 10.5% 109.0 11/23
08 Collection picker popover 31.7% 79.1 6/23
09 Drag reading-list drop 14.5% 43.1 19/23

Item 8's cumulative area (31.7%) is nearly double the next-highest item because a popover panel is the only one of the nine that adds new elements to the layout instead of redrawing the icon in place — every one of its list rows repaints on its own staggered delay. Item 8 now sits at the opposite end too, changing in just 6 of the 23 captured frames — fewer even than item 2's 7/23, where setSaved() only fires on a real click instead of a continuous loop, concentrating its 37.6 average intensity into a handful of frames rather than spreading it thin. Three constants carry most of what separates the nine: BOOKMARK_SPRING_OVERSHOOT_PERCENT (item 3) sets how far past 1 the spring is allowed to travel, BOOKMARK_COUNT_STAGGER_MS (item 6) sets how far apart two digit columns start rolling, and BOOKMARK_DRAG_SNAP_PX (item 9) sets how close a drop has to land before it counts as a save — a bookmark ui design built from any of the nine functions above is really one of these three kinds of number, read at a different rate.

Where It Breaks — Reduced Motion, False Touch Taps, and Double-Saves

Every one of the nine buttons still calls its click or pointerup handler on every input, animated or not, so saved never desyncs from what a screen reader announces even when the motion is skipped — that part holds up across all nine. Item 5's read of prefers-reduced-motion: reduce freezes both values in the same branch. The water level snaps to its target and phase — the value driving the sine ripple across the surface — stops updating too, so the surface holds still at whatever shape it last drew instead of animating toward a flat target.

Item 8 wires ArrowDown or Shift+Enter to open the popover and focus its first item, ArrowUp/ArrowDown to move between items, Escape to close the panel and return focus, and Enter or Space on the button itself to fire a real click (e.detail === 0) that runs the same shortSave() a pointer tap does, with aria-expanded and aria-haspopup="true" announcing the state. Double clicks are the smaller, shared risk across items 1 through 7: none of the nine debounces its click handler, so two fast taps toggle saved twice and land back where it started, visually correct but functionally a no-op that a slower connection or a laggy frame can make look like the tap did nothing. w36j826f sits on the password page for cases like this — open item 8's index.html first to see how the keyboard path above is wired before copying it into a real long-press button.

Where a Bookmark Icon Animation Actually Earns Its Place

Items 1 and 2 are the two built for density: a hover tilt or an outline-to-fill morph reads fine at 20px inside a list of thirty article cards, where nothing else about the row needs to react to a save. Items 3, 4, and 6 want a slightly bigger stage — a comment thread, an article header, a counter badge — somewhere a single save is worth a small celebration because it's not happening thirty times on one screen. This is closer to what a bookmark app ui design actually needs once a save button leaves a demo grid: the motion budget shrinks as the number of icons on screen grows.

Items 7, 8, and 9 are the three driven by something other than a single tap: a toast needs a place at the bottom of the viewport that isn't already occupied, a collection popover needs room below the button for its panel to unfold into, and a drag-to-reorder chip needs an actual second list to drop into. None of the three reads right dropped into a dense grid — they're built for a page, not a card.

Three Variants and the Accessibility Gap Keyboard Users Still Hit

Variant Value change What shifts
Adjust the spring-overshoot ratio value BOOKMARK_SPRING_OVERSHOOT_PERCENT 12 → 38 Lowering the value shrinks the overshoot so the icon grows calmly, and raising it sends the spring far past its size before it snaps back for an exaggerated pop
Adjust the count-badge digit-stagger interval value BOOKMARK_COUNT_STAGGER_MS 40 → 160 Shortening the value makes the digits change almost at once, and lengthening it makes each place roll up visibly one at a time
Adjust the drag-drop snap-distance value BOOKMARK_DRAG_SNAP_PX 24 → 96 Shrinking the value means it only snaps when very close to the target slot, and growing it makes it snap in from much farther away

None of the three variants touch item 9's keyboard behavior — BOOKMARK_SPRING_OVERSHOOT_PERCENT at 12 or at 38 still fires from the same click handler, and BOOKMARK_DRAG_SNAP_PX at 24 or at 96 still reaches the same target through that same click: pressing Enter or Space on the chip toggles saved and snaps it into place exactly like a completed drag. What does hold steady across all nine, variants included: every button carries aria-pressed, and every status text sits in an aria-live="polite" region, so a screen reader always hears "saved" or "not saved" change no matter which input path caused it.

How Is a Bookmark Icon Animation Different From Favorites UI Design?

The two words point at the same click in most products, but favorites ui design usually implies a flat, unordered set — liked or not, no folders, no order — while a bookmark carries the older metaphor of a physical place marker, which is why items 6, 8, and 9 above add a count, a collection, and a position that "favorite" alone doesn't usually need. A shopping site's wishlist heart and a reading app's bookmark ribbon can share the exact same aria-pressed toggle underneath and still feel different to a reader, purely because one implies "I liked this" and the other implies "I'll come back to this exact spot." For nearby patterns built the same way — one value read every frame instead of a fixed preset — 9 CSS Heart Animations covers the liked-or-not half of that distinction, 9 CSS Toast Notifications is the closest relative to item 7's confirmation band, and Drag to Reorder UI goes deeper into the drop-index math that item 9 only touches once per release.

Questions Worth Answering Before You Wire Up a Bookmark Icon Animation

Does Turning Off Motion Also Turn Off the Save Itself?

No, on all nine — the click or pointer handler that flips saved and updates aria-pressed runs the same way whether or not prefers-reduced-motion is set, since none of the nine gate their state change behind an animation finishing. Item 5's wave phase freezes fully to the target fill the instant the setting is on, the same way the other eight settle or skip their own motion right away.

Which of These Nine Is Safe to Use as a Real Save Button Today?

All nine wire a real save toggle to a real click, tap, or keyboard press with no missing keyboard path, so any of them drops into a production button with only its visual identity changing — item 8's popover opens on ArrowDown or Shift+Enter and saves on Enter or Space through a real click event, and item 9's chip saves and snaps into place the same way through that same click. The one thing keyboard input can't reproduce on item 9 is the drag gesture itself — but that gesture was never the only way to reach the saved state.

Can bookmark page ui design Reuse the Same Nine Functions for a List Page Instead of a Single Button?

Yes for items 1 through 6, since each reads its own element references and constants and doesn't assume anything about the page around it; dropping one into every row of a list just means running its setup function once per row instead of once per page. Items 8 and 9 need more care on a list page specifically, because a popover panel or a drag target sized for one button can collide with its neighbors once there are thirty of them stacked in a column.

Enter the archive password

The password is inside this article. You will find it as you read.