All patterns

Trending Rise

Topics travel to their new ranks with direction arrows, and a tint washes over whatever climbed.

socialenergeticminimalautomatic · finite · intermediate · ~2.7s
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.

328 lines · react + motion only
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.

Trending topics listLive ranking boardPopular tags moduleTop posts reordering

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

    The trends panel reorders topics with post counts beside them as interest shifts.

Related patterns