All patterns

New Posts Pill

A count of fresh items drops in at the top; tapping it returns to the top as they insert.

socialminimalfriendlyautomatic · finite · intermediate · ~0.6s
Variant

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.

318 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · New Posts Pill
 *
 * Fresh items are announced, never forced in. The pill drops from the
 * top edge and waits as long as it takes; only on the tap does the feed
 * return to the top and the new items insert on a short stagger.
 * Nothing moves under a reader who did not ask for it.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The pill floats over the feed, so it uses the CSS system colors
 * `Canvas`/`CanvasText` and lands opaque in a light app and in a dark one.
 * Works with zero props; tune via `variant`, `arriveMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type NewPostsPillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Delay before the pill announces itself, in ms from mount. */
  arriveMs?: number;
  /** Scroll offset the feed starts at, so the reader is mid-feed. */
  startScroll?: number;
  /** Fires when the reader accepts the new items. */
  onAccept?: () => void;
};

type Post = {
  id: string;
  name: string;
  initials: string;
  /** Disc color behind the initials — stands in for a photo. */
  tint: string;
  handle: string;
  body: string;
};

type VariantConfig = {
  /** px the pill drops through. */
  drop: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Gap between consecutive inserted items. */
  stagger: number;
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8. The pill
// is a small object arriving over live content: one settle reads as
// arrival, two reads as a glitch in the scroll container.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short, flat drop. For a feed that updates constantly.
  subtle: {
    drop: 18,
    spring: { type: "spring", stiffness: 520, damping: 42 },
    stagger: 0.03,
  },
  // Enough drop to catch the eye at the edge of vision. All-purpose.
  default: {
    drop: 30,
    spring: { type: "spring", stiffness: 420, damping: 34 },
    stagger: 0.05,
  },
  // A longer drop and a wider stagger, for a feed refreshed by hand.
  playful: {
    drop: 40,
    spring: { type: "spring", stiffness: 360, damping: 31 },
    stagger: 0.07,
  },
};

const ACCENT = "#5B5BD6";

const EXISTING: Post[] = [
  {
    id: "e1",
    name: "Amara Osei",
    initials: "AO",
    tint: "#E08A3C",
    handle: "@amara.builds",
    body: "Rewrote the onboarding copy in half the words. Completion is up nine points.",
  },
  {
    id: "e2",
    name: "Jonas Vik",
    initials: "JV",
    tint: "#3FA98B",
    handle: "@jonasvik",
    body: "Two hours of profiling to delete four lines. Worth every minute.",
  },
  {
    id: "e3",
    name: "Priya Sen",
    initials: "PS",
    tint: "#8A7CF0",
    handle: "@priya.sen",
    body: "Our error messages now say what to do next. Support volume halved.",
  },
  {
    id: "e4",
    name: "Theo Lang",
    initials: "TL",
    tint: "#5B8DEF",
    handle: "@theolang",
    body: "Reminder that the fastest query is the one you never send.",
  },
];

const INCOMING: Post[] = [
  {
    id: "n1",
    name: "Rowan Ellis",
    initials: "RE",
    tint: "#D2557A",
    handle: "@rowan.builds",
    body: "Export flow is live. Three screens became one.",
  },
  {
    id: "n2",
    name: "Maya Kwon",
    initials: "MK",
    tint: "#7C7CF0",
    handle: "@mayakwon",
    body: "Shipping the smaller version first was the right call.",
  },
  {
    id: "n3",
    name: "Ines Duarte",
    initials: "ID",
    tint: "#4AA3B8",
    handle: "@inesduarte",
    body: "Wrote up how we cut the cold start in half.",
  },
];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function NewPostsPill({
  variant = "default",
  arriveMs = 900,
  startScroll = 120,
  onAccept,
}: NewPostsPillProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [announced, setAnnounced] = useState(false);
  const [accepted, setAccepted] = useState(false);
  const feedRef = useRef<HTMLDivElement>(null);

  // The reader starts mid-feed, which is the only situation where this
  // pattern exists at all.
  useEffect(() => {
    const feed = feedRef.current;
    if (feed) feed.scrollTop = startScroll;
  }, [startScroll]);

  useEffect(() => {
    const timer = window.setTimeout(() => setAnnounced(true), arriveMs);
    return () => window.clearTimeout(timer);
  }, [arriveMs]);

  const accept = () => {
    setAccepted(true);
    onAccept?.();
    feedRef.current?.scrollTo({
      top: 0,
      behavior: reduceMotion ? "auto" : "smooth",
    });
  };

  const posts = accepted ? [...INCOMING, ...EXISTING] : EXISTING;
  const drop = reduceMotion ? 0 : cfg.drop;

  return (
    <div
      style={{
        position: "relative",
        width: 310,
        borderRadius: 18,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
        overflow: "hidden",
        fontSize: 13.5,
      }}
    >
      <div
        style={{
          padding: "11px 14px",
          borderBottom: `1px solid ${tone(9)}`,
          fontSize: 12.5,
          fontWeight: 600,
        }}
      >
        Following
      </div>

      <div
        ref={feedRef}
        style={{
          height: 250,
          overflowY: "auto",
          padding: "6px 0",
        }}
      >
        {posts.map((post) => {
          const isNew = accepted && INCOMING.some((item) => item.id === post.id);
          const index = INCOMING.findIndex((item) => item.id === post.id);
          return (
            <motion.article
              key={post.id}
              initial={isNew ? { opacity: 0, y: reduceMotion ? 0 : -8 } : false}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                duration: reduceMotion ? 0.16 : 0.3,
                ease: [0.32, 0.72, 0, 1],
                delay: isNew && !reduceMotion ? index * cfg.stagger : 0,
              }}
              style={{
                display: "flex",
                gap: 10,
                padding: "11px 14px",
                borderBottom: `1px solid ${tone(7)}`,
              }}
            >
              <span
                aria-hidden
                style={{
                  flexShrink: 0,
                  display: "grid",
                  placeItems: "center",
                  width: 30,
                  height: 30,
                  borderRadius: "50%",
                  background: post.tint,
                  color: "#ffffff",
                  fontSize: 11.5,
                  fontWeight: 650,
                }}
              >
                {post.initials}
              </span>
              <div style={{ minWidth: 0 }}>
                <div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
                  <span style={{ fontSize: 12.5, fontWeight: 600 }}>{post.name}</span>
                  <span style={{ fontSize: 11, opacity: 0.45 }}>{post.handle}</span>
                </div>
                <p style={{ margin: "2px 0 0", fontSize: 12.5, lineHeight: 1.45 }}>
                  {post.body}
                </p>
              </div>
            </motion.article>
          );
        })}
      </div>

      <AnimatePresence>
        {announced && !accepted && (
          <motion.button
            key="pill"
            type="button"
            onClick={accept}
            initial={{ y: -drop, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            // It leaves the way it came: the exit is what makes the
            // return trip legible instead of a sudden scroll.
            exit={{
              y: -drop,
              opacity: 0,
              transition: { duration: 0.2, ease: "easeIn" },
            }}
            transition={
              reduceMotion ? { duration: 0.16, ease: "easeOut" } : cfg.spring
            }
            style={{
              position: "absolute",
              top: 50,
              left: "50%",
              x: "-50%",
              display: "inline-flex",
              alignItems: "center",
              gap: 6,
              padding: "7px 14px 7px 11px",
              borderRadius: 999,
              // The pill sits over live content, so it cannot be
              // translucent. `Canvas`/`CanvasText` are the CSS system
              // colors for page background and page text — the pill
              // follows the host app's color scheme either way.
              background: "Canvas",
              color: "CanvasText",
              border: `1px solid ${tone(14)}`,
              boxShadow: "0 8px 22px rgba(0,0,0,0.22)",
              fontFamily: "inherit",
              fontSize: 12.5,
              fontWeight: 600,
              cursor: "pointer",
            }}
          >
            <span style={{ display: "grid", placeItems: "center", color: ACCENT }}>
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
                <path
                  d="M8 13V3.4M8 3.4 3.9 7.5M8 3.4l4.1 4.1"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </span>
            {INCOMING.length} new posts
          </motion.button>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

Inserting new items above someone who is mid-read is the rudest thing a feed can do, so nothing moves until it is asked to. The pill drops in from the top edge, waits as long as it takes, and only on the tap does the feed return to the top and the new items arrive on a short stagger. The pill leaves the way it came, which is what makes the return trip legible rather than a sudden scroll.

New items available in a feedUnread updates while scrolled downLive results arriving behind the foldReturn to the top of a timeline

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Marcus Bell5hNew supplier signed. Same rate, twelve more months.
    423
    Social feed

    A floating count waits above the timeline until the reader asks for it.

Related patterns