Trending Rise
Topics travel to their new ranks with direction arrows, and a tint washes over whatever climbed.
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, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Trending Rise
*
* A trending list that reorders in front of you: rows travel to their new
* ranks instead of blinking into them, movement arrows say which
* direction each one went, and a tint washes over whatever climbed.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `updatesMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type HashtagTrendRiseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** When each reshuffle lands, in ms from mount. */
updatesMs?: number[];
/** Called with the new leading topic after each reshuffle. */
onLeadChange?: (tag: string) => void;
/** Climbing color. A state color, so it stays literal. */
accent?: string;
};
type VariantConfig = {
/** Seconds the climb tint takes to wash out. */
wash: number;
/** Peak opacity of that tint. */
washOpacity: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: springs at or above a 0.8 damping ratio
// (damping / 2√stiffness). Rows carry type, so they move with
// position-only layout animation and never overshoot their new rank —
// a list that rebounds is a list you have to re-read.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and businesslike. For a sidebar module.
subtle: {
wash: 0.5,
washOpacity: 0.1,
spring: { type: "spring", stiffness: 620, damping: 46 },
},
// Slow enough to follow a row across. All-purpose.
default: {
wash: 0.7,
washOpacity: 0.14,
spring: { type: "spring", stiffness: 480, damping: 40 },
},
// A longer travel and a brighter wash, for a live trends page.
playful: {
wash: 0.9,
washOpacity: 0.18,
spring: { type: "spring", stiffness: 380, damping: 34 },
},
};
const ACCENT = "#3FA98C";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` yields surfaces and borders correctly toned on a light
* page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const TOPICS: Record<string, { tag: string; note: string }> = {
ds: { tag: "#DesignSystems", note: "Product · Trending" },
rw: { tag: "#RemoteWork", note: "Business · Trending" },
ty: { tag: "#Typography", note: "Design · Trending" },
ax: { tag: "#Accessibility", note: "Product · Trending" },
pr: { tag: "#PricingPages", note: "Business · Trending" },
};
type Round = { order: string[]; posts: Record<string, string> };
const ROUNDS: readonly Round[] = [
{
order: ["ds", "rw", "ty", "ax", "pr"],
posts: { ds: "24.1K", rw: "18.7K", ty: "12.4K", ax: "9.8K", pr: "7.2K" },
},
{
order: ["rw", "ds", "ax", "ty", "pr"],
posts: { rw: "31.5K", ds: "26.0K", ax: "16.2K", ty: "13.1K", pr: "7.6K" },
},
{
order: ["ax", "rw", "ds", "pr", "ty"],
posts: { ax: "42.8K", rw: "34.2K", ds: "27.4K", pr: "15.9K", ty: "13.4K" },
},
];
export default function HashtagTrendRise({
variant = "default",
updatesMs = [1100, 2700],
onLeadChange,
accent = ACCENT,
}: HashtagTrendRiseProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [round, setRound] = useState(0);
// The callback is read through a ref so an inline function from the
// parent can't re-fire the notification on every render.
const notify = useRef(onLeadChange);
useEffect(() => {
notify.current = onLeadChange;
}, [onLeadChange]);
const schedule = updatesMs.join(",");
useEffect(() => {
const timers = schedule
.split(",")
.map(Number)
.slice(0, ROUNDS.length - 1)
.map((delay, index) => setTimeout(() => setRound(index + 1), delay));
return () => timers.forEach(clearTimeout);
}, [schedule]);
useEffect(() => {
notify.current?.(TOPICS[ROUNDS[round].order[0]].tag);
}, [round]);
const current = ROUNDS[round];
const previous = ROUNDS[Math.max(round - 1, 0)];
const settle = reduceMotion ? { duration: 0 } : cfg.spring;
return (
<div
style={{
width: 320,
borderRadius: 18,
border: `1px solid ${tone(11)}`,
background: tone(4),
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "13px 15px 11px",
borderBottom: `1px solid ${tone(9)}`,
}}
>
<span style={{ fontSize: 12, fontWeight: 650, letterSpacing: 0.4 }}>
TRENDING NOW
</span>
<span style={{ fontSize: 10.5, opacity: 0.45 }}>refreshed live</span>
</div>
<div style={{ padding: "5px 0 7px" }}>
{current.order.map((key, index) => {
const topic = TOPICS[key];
const previousRank = previous.order.indexOf(key);
const moved = round === 0 ? 0 : previousRank - index;
return (
<motion.div
key={key}
// layout="position" carries the row to its new rank without
// stretching it, so the type inside is never scaled.
layout={reduceMotion ? false : "position"}
transition={{ layout: settle }}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 11,
padding: "9px 15px",
}}
>
{/* A wash over whatever climbed: the row is briefly warm, and
then it is just a row again. */}
<AnimatePresence>
{moved > 0 && !reduceMotion && (
<motion.span
key={`${key}-${round}`}
aria-hidden
initial={{ opacity: cfg.washOpacity }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: cfg.wash, ease: "easeOut" }}
style={{
position: "absolute",
inset: "2px 8px",
borderRadius: 10,
background: accent,
}}
/>
)}
</AnimatePresence>
<span
style={{
position: "relative",
width: 14,
height: 15,
flexShrink: 0,
fontSize: 12,
fontWeight: 650,
opacity: 0.35,
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={index + 1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, lineHeight: "15px" }}
>
{index + 1}
</motion.span>
</AnimatePresence>
</span>
<span style={{ flex: 1, minWidth: 0, position: "relative" }}>
<span style={{ display: "block", fontSize: 13, fontWeight: 600 }}>
{topic.tag}
</span>
<span style={{ display: "block", fontSize: 10.5, opacity: 0.42, marginTop: 2 }}>
{topic.note}
</span>
</span>
<span
style={{
position: "relative",
width: 42,
height: 15,
flexShrink: 0,
fontSize: 11.5,
opacity: 0.55,
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={current.posts[key]}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
lineHeight: "15px",
textAlign: "right",
}}
>
{current.posts[key]}
</motion.span>
</AnimatePresence>
</span>
{/* Direction is a glyph, not a color alone: the arrow says
which way the row went even where the tint does not read. */}
<span
style={{
position: "relative",
width: 13,
height: 15,
flexShrink: 0,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={`${moved > 0 ? "up" : moved < 0 ? "down" : "flat"}-${round}`}
initial={{
opacity: 0,
y: reduceMotion ? 0 : moved > 0 ? 5 : moved < 0 ? -5 : 0,
}}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
color: moved > 0 ? accent : "inherit",
opacity: moved > 0 ? 1 : 0.32,
}}
>
{moved === 0 ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 12h12"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
) : (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
aria-hidden
style={{ transform: moved < 0 ? "rotate(180deg)" : undefined }}
>
<path
d="M12 19V6M6 12l6-6 6 6"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</motion.span>
</AnimatePresence>
</span>
</motion.div>
);
})}
</div>
</div>
);
}About this pattern
A ranking that changes by re-rendering teaches the reader nothing: the third row is suddenly first and there is no way to know it moved. Here every row travels to its new position, so the change is legible as movement rather than as a difference between two screenshots. Rows carry type, so they move with position-only layout animation and land without rebound — a list that overshoots is a list you have to read twice. Direction is stated twice over, as an arrow glyph and as a brief tint on whatever climbed, since color alone is not a message everyone receives. Ranks and counts crossfade inside fixed-width tabular slots, so a figure changing never nudges the row it sits in.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Social feed
The trends panel reorders topics with post counts beside them as interest shifts.
Related patterns
- Avatar Stack OverflowParticipants slide into the overlapping stack and the overflow count takes over.
- Feed Refresh InsertPosts land above the fold and the line being read never moves — only a counter admits they arrived.
- Group JoinA joining member's avatar drops into the stack, the others make room, and the count rolls by one.