Bookmark Save
The mark fills from the notch up, tucks into its corner, and a ribbon slides down behind the card edge.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Social feed
The save mark under a post fills on tap and offers the collection the post landed in.
Related patterns
- Share Sheet PresentThe panel rises over a dimmed page and its destinations arrive in a quick wave.
- Swipe to ReplyDragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.
- Follow Button SwapFollow becomes Following in place — the button never changes width.