Notification Arrive
The bell tips once and the badge appears — restrained, because this fires all day.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Notification center
A dot on the header bell is the entire announcement.