Trophy Shelf Add
A new award settles into the first slot while the awards already there slide over to make room.
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 { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Trophy Shelf Add
*
* A new award arriving on a shelf that already has things on it. The
* award settles into the leftmost slot and the awards already there
* slide over to make the room — the neighbours moving is what turns a
* list update into a placement.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Plinths and the shelf line are mixed from the inherited text color;
* the new award's tint is semantic and stays literal.
* Works with zero props; tune via `variant`, `awards`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type Award = {
id: string;
/** Which engraved mark to draw. */
mark: "cup" | "medal" | "shield" | "star" | "rosette";
label: string;
};
export type TrophyShelfAddProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Awards already on the shelf, left to right. */
awards?: Award[];
/** The award that arrives. */
incoming?: Award;
/** Tint for the arriving award. Semantic, so it stays literal. */
accent?: string;
/** Fires once the new award has settled. */
onPlaced?: () => void;
};
type VariantConfig = {
/** Beat before the award arrives, so the shelf is read as it was. */
delay: number;
/** How far above its slot the award starts. */
drop: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Damped at 0.85 and above: the award lands, compresses nothing, and
// stops. A trophy that bounces twice on the shelf is a toy falling over.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
subtle: {
delay: 0.24,
drop: 8,
spring: { type: "spring", stiffness: 540, damping: 44 },
},
default: {
delay: 0.38,
drop: 16,
spring: { type: "spring", stiffness: 400, damping: 34 },
},
playful: {
delay: 0.5,
drop: 24,
spring: { type: "spring", stiffness: 330, damping: 30 },
},
};
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const MARKS: Record<Award["mark"], string[]> = {
cup: [
"M7.6 4.2h8.8v4.2a4.4 4.4 0 0 1-8.8 0z",
"M7.6 5.4H5.5a2.4 2.4 0 0 0 2.1 3.5M16.4 5.4h2.1a2.4 2.4 0 0 1-2.1 3.5",
"M12 12.8v3.2M8.7 17.8h6.6",
],
medal: ["M9 3.2 10.9 9.4M15 3.2 13.1 9.4", "M12 14.6m-4.7 0a4.7 4.7 0 1 0 9.4 0a4.7 4.7 0 1 0-9.4 0"],
shield: [
"M12 3.4 18.4 5.7v5c0 4-2.7 7.1-6.4 8.3-3.7-1.2-6.4-4.3-6.4-8.3v-5z",
"M9.3 11.5 11.3 13.5 15 9.3",
],
star: [
"M12 12m-7.4 0a7.4 7.4 0 1 0 14.8 0a7.4 7.4 0 1 0-14.8 0",
"M12 7.7 13.3 10.6 16.4 10.9 14 12.9 14.8 16 12 14.3 9.2 16 10 12.9 7.6 10.9 10.7 10.6Z",
],
rosette: [
"M12 9.2m-5 0a5 5 0 1 0 10 0a5 5 0 1 0-10 0",
"M9.1 13.4 8 20.6l4-2.3 4 2.3-1.1-7.2",
],
};
const DEFAULT_AWARDS: Award[] = [
{ id: "cup", mark: "cup", label: "Top contributor" },
{ id: "shield", mark: "shield", label: "Zero defects" },
{ id: "star", mark: "star", label: "Five-star review" },
{ id: "medal", mark: "medal", label: "Fastest response" },
];
const INCOMING: Award = {
id: "rosette",
mark: "rosette",
label: "Regional finalist",
};
function Mark({ mark }: { mark: Award["mark"] }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{MARKS[mark].map((d, index) => (
<path key={index} d={d} />
))}
</svg>
);
}
export default function TrophyShelfAdd({
variant = "default",
awards = DEFAULT_AWARDS,
incoming = INCOMING,
accent = "#B0873C",
onPlaced,
}: TrophyShelfAddProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
// Reduced motion: the award is simply already on the shelf. Both that
// and the reset a new run needs are render-time facts, so the run key
// lives in state and is compared during render; the effect is left
// owning the one thing that genuinely is asynchronous — the timer.
const runKey = `${still}:${cfg.delay}`;
const [run, setRun] = useState({ key: runKey, placed: still });
if (run.key !== runKey) setRun({ key: runKey, placed: still });
const placed = run.key === runKey ? run.placed : still;
useEffect(() => {
if (still) return;
const timer = setTimeout(
() => setRun({ key: runKey, placed: true }),
cfg.delay * 1000
);
return () => clearTimeout(timer);
}, [still, cfg.delay, runKey]);
const shelf = placed ? [incoming, ...awards] : awards;
return (
<div style={{ width: 292, display: "flex", flexDirection: "column", gap: 10 }}>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 8,
height: 62,
}}
>
<AnimatePresence initial={false}>
{shelf.map((award) => {
const isNew = award.id === incoming.id;
return (
<motion.div
key={award.id}
layout={!still}
title={award.label}
initial={
isNew
? still
? { opacity: 0 }
: { opacity: 0, scale: 0.82, y: -cfg.drop }
: false
}
animate={
still ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={
still ? { duration: 0.18, ease: "easeOut" } : cfg.spring
}
onAnimationComplete={
isNew && onPlaced ? () => onPlaced() : undefined
}
style={{
display: "grid",
placeItems: "center",
width: 52,
height: 58,
flex: "none",
borderRadius: 13,
background: isNew
? `color-mix(in srgb, ${accent} 13%, transparent)`
: tone(6),
border: `1px solid ${isNew ? `color-mix(in srgb, ${accent} 34%, transparent)` : tone(11)}`,
color: isNew ? accent : tone(58),
}}
>
<Mark mark={award.mark} />
</motion.div>
);
})}
</AnimatePresence>
</div>
{/* The shelf itself. Static: the surface does not react, the object
placed on it does. */}
<div
aria-hidden
style={{
height: 2,
borderRadius: 2,
background: `linear-gradient(90deg, ${tone(4)}, ${tone(16)}, ${tone(4)})`,
}}
/>
<div style={{ minHeight: 16 }}>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={placed ? "after" : "before"}
initial={{ opacity: 0, y: still ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: still ? 0 : -4 }}
transition={{ duration: still ? 0 : 0.24, ease: "easeOut" }}
style={{
display: "inline-block",
fontSize: 11.5,
fontWeight: placed ? 600 : 500,
color: placed ? accent : tone(50),
}}
>
{placed
? `${incoming.label} added`
: `${awards.length} awards`}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}About this pattern
Adding to a collection, staged as a placement rather than a list update. The new award drops a short distance into the leftmost slot on an over-damped spring while every award already on the shelf animates sideways to the position it now occupies. The neighbours moving is the load-bearing half: without it the shelf simply redraws with one more thing on it, and the viewer has no way to tell what is new. The shelf line under the row deliberately does not react — the surface stays still so the object placed on it reads as the thing that moved. Marks are inline SVG, so there are no image assets to ship with the copied file.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Achievements
Award tiles inserted into a profile row with the existing tiles reflowing.
Related patterns
- Certificate IssueThe border strokes itself around the sheet, the seal settles on, and the name is written last.
- Rank PromotionThe old tier ring fades while the new tier's ring strokes itself around the badge.
- Year in Review StatA headline figure counts up over two deliberate seconds, then the line that explains it arrives.