Feed Refresh Insert
Posts land above the fold and the line being read never moves — only a counter admits they arrived.
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, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Feed Refresh Insert
*
* New items arrive above the fold and the line being read does not move.
* The insert is compensated in the same frame it happens — scroll offset
* grows by exactly the height that was added — so the only thing that
* animates is the counter admitting the new posts are up there.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Scroll is read from this component's own pane, so it works inside a
* card or a modal. Surfaces are mixed from the inherited text color and
* read correctly on light and dark pages.
* Works with zero props; tune via `variant`, `arrivalsMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FeedRefreshInsertProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** When each batch of new items lands, in ms from mount. */
arrivalsMs?: number[];
/** Where the reader starts, in px from the top of the feed. */
startOffset?: number;
/** Height of the scrolling pane, in px. */
height?: number;
/** New-item color. A state color, so it stays literal. */
accent?: string;
};
type VariantConfig = {
/** px a newly inserted row travels as it fades in. */
lift: number;
/** Seconds for the jump back to the top of the feed. */
scrollSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: springs at or above a 0.8 damping ratio
// (damping / 2√stiffness). The counter is the only thing allowed to move
// into view; anything springier would undo the point of the pattern,
// which is that the feed stays still while it grows.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The counter barely announces itself. For a high-volume feed.
subtle: {
lift: 4,
scrollSeconds: 0.4,
spring: { type: "spring", stiffness: 620, damping: 46 },
},
// Enough to catch the eye without pulling it. All-purpose.
default: {
lift: 8,
scrollSeconds: 0.55,
spring: { type: "spring", stiffness: 500, damping: 40 },
},
// A longer glide back to the top, for a leisurely reading surface.
playful: {
lift: 12,
scrollSeconds: 0.7,
spring: { type: "spring", stiffness: 400, 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)`;
type Post = { id: number; author: string; handle: string; body: string; meta: string };
const SEED: readonly Post[] = [
{ id: 4, author: "Priya Raman", handle: "@praman", body: "Cut the settings page from nine sections to four. Nobody has asked where anything went.", meta: "22m" },
{ id: 3, author: "Tomas Lund", handle: "@tlund", body: "Reminder that a changelog nobody reads is still cheaper than a support queue.", meta: "51m" },
{ id: 2, author: "Adaeze Okoro", handle: "@adaeze", body: "Spent the morning deleting code. Best release note of the quarter.", meta: "1h" },
{ id: 1, author: "Jonah Weiss", handle: "@jweiss", body: "Our onboarding drop-off was a loading state, not a feature gap.", meta: "2h" },
];
const INCOMING: readonly Post[] = [
{ id: 5, author: "Marisol Vega", handle: "@marisolbuilds", body: "Shipped the compact density option. It was three lines and four months of arguing.", meta: "now" },
{ id: 6, author: "Kenji Hara", handle: "@kenjih", body: "A good empty state is just documentation with better manners.", meta: "now" },
{ id: 7, author: "Lena Fischer", handle: "@lenaf", body: "Every metric we added this quarter made one decision easier. That was the rule.", meta: "now" },
];
export default function FeedRefreshInsert({
variant = "default",
arrivalsMs = [1200, 2600],
startOffset = 104,
height = 300,
accent = ACCENT,
}: FeedRefreshInsertProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const paneRef = useRef<HTMLDivElement>(null);
const measuredRef = useRef(0);
const compensateRef = useRef(false);
const [arrived, setArrived] = useState(0);
const [unseen, setUnseen] = useState(0);
const posts = [...INCOMING.slice(0, arrived).reverse(), ...SEED];
// The reader starts mid-feed, which is the only situation in which any
// of this matters.
useLayoutEffect(() => {
const pane = paneRef.current;
if (pane) pane.scrollTop = startOffset;
}, [startOffset]);
const schedule = arrivalsMs.join(",");
useEffect(() => {
const timers = schedule
.split(",")
.map(Number)
.slice(0, INCOMING.length)
.map((delay, index) =>
setTimeout(() => {
const pane = paneRef.current;
if (pane) {
measuredRef.current = pane.scrollHeight;
compensateRef.current = true;
}
setArrived(index + 1);
setUnseen(index + 1);
}, delay)
);
return () => timers.forEach(clearTimeout);
}, [schedule]);
// The whole pattern, in three lines: whatever height the insert added
// above the viewport is handed straight to scrollTop, before the browser
// paints. The row under the reader's eyes never moves.
useLayoutEffect(() => {
const pane = paneRef.current;
if (!pane || !compensateRef.current) return;
compensateRef.current = false;
const added = pane.scrollHeight - measuredRef.current;
if (added > 0) pane.scrollTop += added;
}, [arrived]);
const jumpToTop = () => {
const pane = paneRef.current;
if (!pane) return;
setUnseen(0);
if (reduceMotion) {
pane.scrollTop = 0;
return;
}
const from = pane.scrollTop;
animate(from, 0, {
duration: cfg.scrollSeconds,
ease: [0.32, 0.72, 0, 1],
onUpdate: (value) => {
pane.scrollTop = value;
},
});
};
return (
<div
style={{
position: "relative",
width: 330,
height,
overflow: "hidden",
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(4),
}}
>
{/* The counter is the only thing that moves on arrival: it drops in
from above the pane's top edge, where the new posts also are. */}
<div
style={{
position: "absolute",
top: 10,
left: 0,
right: 0,
zIndex: 3,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
}}
>
<AnimatePresence>
{unseen > 0 && (
<motion.button
key="counter"
type="button"
onClick={jumpToTop}
initial={{ opacity: 0, y: reduceMotion ? 0 : -14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -10 }}
transition={{
opacity: { duration: 0.18, ease: "easeOut" },
y: reduceMotion ? { duration: 0 } : cfg.spring,
}}
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 999,
border: 0,
background: accent,
color: "#fff",
fontFamily: "inherit",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.18)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M12 19V5M6 11l6-6 6 6"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{unseen} new {unseen === 1 ? "post" : "posts"}
</motion.button>
)}
</AnimatePresence>
</div>
<div
ref={paneRef}
onScroll={(event) => {
if (event.currentTarget.scrollTop < 8) setUnseen(0);
}}
style={{
position: "absolute",
inset: 0,
overflowY: "auto",
overflowX: "hidden",
touchAction: "pan-y",
}}
>
{posts.map((post) => {
const isNew = post.id > SEED.length;
return (
<motion.article
key={post.id}
initial={isNew ? { opacity: 0, y: reduceMotion ? 0 : -cfg.lift } : false}
animate={{ opacity: 1, y: 0 }}
transition={{
opacity: { duration: 0.3, ease: "easeOut" },
y: reduceMotion ? { duration: 0 } : cfg.spring,
}}
style={{
display: "flex",
gap: 10,
padding: "12px 14px",
borderBottom: `1px solid ${tone(8)}`,
}}
>
<span
aria-hidden
style={{
width: 28,
height: 28,
flexShrink: 0,
borderRadius: "50%",
display: "grid",
placeItems: "center",
fontSize: 10.5,
fontWeight: 600,
color: "#fff",
background: isNew
? "linear-gradient(140deg,#3FA98C,#2C7F8F)"
: "linear-gradient(140deg,#8A8FA3,#5C6274)",
}}
>
{post.author
.split(" ")
.map((part) => part[0])
.join("")}
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
}}
>
<strong style={{ fontWeight: 650 }}>{post.author}</strong>
<span style={{ opacity: 0.42 }}>{post.handle}</span>
<span style={{ opacity: 0.42 }}>· {post.meta}</span>
{isNew && (
<span
style={{
width: 5,
height: 5,
borderRadius: 3,
background: accent,
}}
/>
)}
</span>
<span
style={{
display: "block",
fontSize: 12.5,
lineHeight: 1.45,
marginTop: 3,
opacity: 0.82,
}}
>
{post.body}
</span>
</span>
</motion.article>
);
})}
</div>
</div>
);
}About this pattern
The failure this pattern exists to prevent is losing your place: content is inserted at the top of a feed, the page grows, and the sentence you were halfway through jumps out from under you. The fix is not an animation, it is an accounting — the height added above the viewport is handed straight to the scroll offset in the same frame, before the browser paints, so the visible rows are mathematically pinned. What is left to animate is small on purpose: the new rows fade up in the space nobody is looking at, and a counter drops in from the top edge to say how many are waiting. Tapping it glides the feed home; scrolling up by hand clears it, because arriving at the top is the same event as acknowledging them.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Social feed
New posts accumulate behind a counter instead of pushing the timeline down.
Related patterns
- New Posts PillA count of fresh items drops in at the top; tapping it returns to the top as they insert.
- Unread DividerA marker draws across the thread where you stopped reading, then fades without moving anything.
- Comment Thread ExpandReplies unfold beneath a comment while the indent guide draws down beside them.