All patterns

Notification Arrive

The bell tips once and the badge appears — restrained, because this fires all day.

socialfriendlysubtleautomatic · finite · starter · ~0.5s
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.

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

/**
 * Vibary · Notification Arrive
 *
 * One tip, one small return swing, then stillness. Arrival motion is
 * judged on its hundredth run: a bell that jingles is charming once and
 * grating by lunchtime. The badge does the lasting work — the tip only
 * says the badge is new.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The header is mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `arriveMs`, `count`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type NotificationBellRingProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Delay before the notification lands, in ms from mount. */
  arriveMs?: number;
  /** How many unread items the badge stands for. */
  count?: number;
  title?: string;
  /** Fires when the badge is cleared. */
  onClear?: () => void;
};

type VariantConfig = {
  /** Degrees of the first tip. */
  angle: number;
  tipSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// The badge spring is at or above a 0.8 damping ratio
// (damping / 2√stiffness), and the tip is a fixed keyframe sequence
// rather than a spring: a spring on a rotation this visible is one
// stiffness change away from becoming a jingle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a lean. For an app that notifies constantly.
  subtle: {
    angle: 4,
    tipSeconds: 0.39,
    spring: { type: "spring", stiffness: 560, damping: 43 },
  },
  // A readable tip. All-purpose.
  default: {
    angle: 11,
    tipSeconds: 0.5,
    spring: { type: "spring", stiffness: 440, damping: 36 },
  },
  // A wider tip, for an app where a notification is an event.
  playful: {
    angle: 17,
    tipSeconds: 0.61,
    spring: { type: "spring", stiffness: 360, damping: 31 },
  },
};

const BADGE = "#E8365D";

/** 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 NotificationBellRing({
  variant = "default",
  arriveMs = 900,
  count = 1,
  title = "Home",
  onClear,
}: NotificationBellRingProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [arrived, setArrived] = useState(false);
  const [cleared, setCleared] = useState(false);

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

  const unread = arrived && !cleared;

  const clear = () => {
    setCleared(true);
    onClear?.();
  };

  return (
    <div
      style={{
        width: 300,
        borderRadius: 16,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
        overflow: "hidden",
        fontSize: 13.5,
      }}
    >
      <header
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          padding: "12px 14px",
          borderBottom: `1px solid ${tone(9)}`,
        }}
      >
        <span style={{ fontSize: 15, fontWeight: 650 }}>{title}</span>

        <button
          type="button"
          onClick={clear}
          aria-label={
            unread ? `Notifications, ${count} unread` : "Notifications, none unread"
          }
          style={{
            position: "relative",
            display: "grid",
            placeItems: "center",
            width: 36,
            height: 36,
            borderRadius: 10,
            border: 0,
            background: "none",
            color: "inherit",
            cursor: "pointer",
          }}
        >
          <motion.span
            aria-hidden
            // Re-keyed on arrival so the sequence runs exactly once,
            // when the notification lands — never on every render.
            key={unread ? "tipped" : "still"}
            initial={{ rotate: 0 }}
            animate={
              unread && !reduceMotion
                ? { rotate: [0, -cfg.angle, cfg.angle * 0.5, 0] }
                : { rotate: 0 }
            }
            transition={
              unread && !reduceMotion
                ? {
                    duration: cfg.tipSeconds,
                    ease: "easeOut",
                    // One tip, one small return swing, then still.
                    times: [0, 0.28, 0.62, 1],
                  }
                : { duration: 0 }
            }
            style={{
              display: "grid",
              placeItems: "center",
              // A bell pivots where it hangs, not at its middle.
              transformOrigin: "50% 15%",
            }}
          >
            <svg width="21" height="21" viewBox="0 0 24 24" fill="none">
              <path
                d="M12 3.4a5.6 5.6 0 0 0-5.6 5.6v3.1L5 15.3a.8.8 0 0 0 .7 1.2h12.6a.8.8 0 0 0 .7-1.2l-1.4-3.2V9A5.6 5.6 0 0 0 12 3.4Z"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinejoin="round"
              />
              <path
                d="M9.9 19.1a2.2 2.2 0 0 0 4.2 0"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinecap="round"
              />
            </svg>
          </motion.span>

          <AnimatePresence>
            {unread && (
              <motion.span
                key="badge"
                aria-hidden
                initial={{ scale: reduceMotion ? 1 : 0.3, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={{ scale: reduceMotion ? 1 : 0.4, opacity: 0, transition: { duration: 0.14 } }}
                transition={
                  reduceMotion
                    ? { duration: 0.16, ease: "easeOut" }
                    : { ...cfg.spring, delay: 0.06 }
                }
                style={{
                  position: "absolute",
                  top: 6,
                  right: 7,
                  width: 9,
                  height: 9,
                  borderRadius: "50%",
                  background: BADGE,
                  // A ring in the page color separates the badge from
                  // the glyph it sits on, in either theme.
                  boxShadow: "0 0 0 2px Canvas",
                }}
              />
            )}
          </AnimatePresence>
        </button>
      </header>

      <div style={{ padding: "14px 14px 16px" }}>
        <div role="status" style={{ fontSize: 12.5, opacity: 0.55, minHeight: 18 }}>
          {unread
            ? `${count} new ${count === 1 ? "notification" : "notifications"}`
            : "You are all caught up"}
        </div>
        <div
          aria-hidden
          style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 12 }}
        >
          {[92, 74, 58].map((width) => (
            <span
              key={width}
              style={{
                height: 9,
                width: `${width}%`,
                borderRadius: 5,
                background: tone(7),
              }}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

About this pattern

Arrival motion is judged on its hundredth run, not its first. A bell that jingles is charming once and grating by lunchtime, so this is a single tip with one small return swing and nothing after it: the badge does the lasting work, and the tip only says the badge is new. Tapping the bell clears the badge on a short scale-out. Nothing here loops, and nothing repeats — that restraint is the whole design.

New notification arrivingUnread badge appearingInbox count changingBackground event surfacing in a header

Where it shows up

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

  • 10:15
    Notifications
    Your order cleared2 minutes ago
    Priya replied to your thread18 minutes ago
    Weekly summary is readyYesterday
    Marcus joined the workspaceTuesday
    HomeSearchActivityProfile
    Notification center

    A dot on the header bell is the entire announcement.

Related patterns