Presence Dot
The status dot eases between tints, settles once, and sends a single ring out on coming online.
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,
animate,
motion,
useMotionValue,
useReducedMotion,
} from "motion/react";
/**
* Vibary · Presence Dot
*
* Presence changes state, not just color: the dot eases between tints,
* settles once, and sends a single ring out when someone comes online —
* with a shape cue inside it, so the state survives being seen in grey.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The hole punched in the dot is the page's own background color via the
* `Canvas` system color, so the shape reads on light and dark pages.
* Works with zero props; tune via `variant`, `status`, `stepsMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PresenceDotOnlineProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive it from your own presence events. "auto" walks the states. */
status?: "auto" | "online" | "away" | "offline";
/** In "auto": when each state lands, in ms from mount. */
stepsMs?: number[];
name?: string;
role?: string;
initials?: string;
/** Diameter of the dot, in px. */
size?: number;
};
type VariantConfig = {
/** Seconds for the color to cross to its new state. */
tint: number;
/** How much the dot settles when the state changes. */
pop: number;
/** How far the arrival ring travels before it is spent. */
ring: number;
};
// Quality rule: one settle, never a wobble. The pop is a three-keyframe
// tween rather than a spring so the dot cannot ring around its own size,
// and the arrival ring fires once per event instead of looping — a
// presence dot that pulses forever is a distraction with no news in it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Just the tint and a whisper of a settle. For a dense member list.
subtle: { tint: 0.26, pop: 1.1, ring: 1.9 },
// The state change is noticeable at a glance. All-purpose.
default: { tint: 0.32, pop: 1.18, ring: 2.4 },
// A wider ring and a fuller settle, for a single prominent avatar.
playful: { tint: 0.4, pop: 1.26, ring: 3 },
};
/** Status colors are semantic, so they stay literal. */
const STATUS = {
online: { color: "#3FA98C", label: "Active now" },
away: { color: "#D9A115", label: "Away · 12m" },
offline: { color: "#8A8FA3", label: "Offline" },
} as const;
type Status = keyof typeof STATUS;
const SEQUENCE: readonly Status[] = ["offline", "online", "away", "online"];
/** 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)`;
export default function PresenceDotOnline({
variant = "default",
status = "auto",
stepsMs = [900, 2200, 3500],
name = "Kenji Hara",
role = "Support · Berlin",
initials = "KH",
size = 12,
}: PresenceDotOnlineProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [step, setStep] = useState(0);
const pop = useMotionValue(1);
const schedule = stepsMs.join(",");
useEffect(() => {
if (status !== "auto") return;
const timers = schedule
.split(",")
.map(Number)
.slice(0, SEQUENCE.length - 1)
.map((delay, index) => setTimeout(() => setStep(index + 1), delay));
return () => timers.forEach(clearTimeout);
}, [status, schedule]);
const state: Status =
status === "auto" ? SEQUENCE[Math.min(step, SEQUENCE.length - 1)] : status;
const { color, label } = STATUS[state];
// The settle is feedback for a change, so it runs on the transition
// rather than on a timeline of its own.
useEffect(() => {
if (reduceMotion) return;
const controls = animate(pop, [1, cfg.pop, 1], {
duration: 0.36,
times: [0, 0.4, 1],
ease: "easeOut",
});
return () => controls.stop();
}, [state, reduceMotion, cfg.pop, pop]);
// Shape, not color alone: a hole offset up-left reads as away, a
// centred hole reads as offline, and no hole at all is online.
const hole =
state === "away"
? { opacity: 1, x: -size * 0.14, y: -size * 0.14, scale: 0.62 }
: state === "offline"
? { opacity: 1, x: 0, y: 0, scale: 0.5 }
: { opacity: 0, x: 0, y: 0, scale: 0.3 };
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 11,
width: 300,
padding: "11px 13px",
borderRadius: 14,
border: `1px solid ${tone(11)}`,
background: tone(4),
}}
>
<span style={{ position: "relative", flexShrink: 0 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 36,
height: 36,
borderRadius: "50%",
fontSize: 12.5,
fontWeight: 600,
color: "#fff",
background: "linear-gradient(140deg,#4CC0E0,#4C7DF0)",
}}
>
{initials}
</span>
<span
style={{
position: "absolute",
right: -1,
bottom: -1,
display: "grid",
placeItems: "center",
width: size,
height: size,
}}
>
{/* One ring per arrival — the news is that they came online, and
news does not repeat itself every two seconds. */}
<AnimatePresence>
{state === "online" && !reduceMotion && (
<motion.span
key={`ring-${step}`}
aria-hidden
initial={{ scale: 0.7, opacity: 0.5 }}
animate={{ scale: cfg.ring, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.7, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
border: `1.5px solid ${STATUS.online.color}`,
}}
/>
)}
</AnimatePresence>
<motion.span
initial={false}
animate={{ backgroundColor: color }}
transition={{ duration: reduceMotion ? 0 : cfg.tint, ease: "easeOut" }}
style={{
position: "relative",
width: size,
height: size,
borderRadius: "50%",
scale: pop,
// An opaque separator from the avatar behind it, in the
// page's own background color.
boxShadow: "0 0 0 2px Canvas",
}}
>
<motion.span
aria-hidden
initial={false}
animate={hole}
transition={{
duration: reduceMotion ? 0 : cfg.tint,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
background: "Canvas",
}}
/>
</motion.span>
</span>
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span style={{ display: "block", fontSize: 13, fontWeight: 600 }}>{name}</span>
{/* Fixed-height slot: the wording changes without the row growing. */}
<span
role="status"
style={{ position: "relative", display: "block", height: 15, marginTop: 1 }}
>
<AnimatePresence initial={false}>
<motion.span
key={state}
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 11.5,
lineHeight: "15px",
color: state === "online" ? STATUS.online.color : "inherit",
opacity: state === "online" ? 1 : 0.5,
}}
>
{label}
</motion.span>
</AnimatePresence>
</span>
</span>
<span style={{ fontSize: 10.5, opacity: 0.35, flexShrink: 0 }}>{role}</span>
</div>
);
}About this pattern
Presence is the smallest status in an interface and one of the most watched, which makes restraint the whole design. The dot crosses between its state colors on a short ease and settles once — a three-keyframe tween rather than a spring, so a twelve-pixel mark can never ring around its own size — and coming online sends exactly one expanding ring, because a dot that pulses forever is a distraction carrying no news. State is never left to color alone: a hole punched off-centre reads as away, a centred hole reads as offline, and a solid disc is online, with the hole taking the page's own background color so the shape holds up in either theme. The wording underneath swaps in a fixed-height slot, so the row never grows when someone steps away.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
A member's presence marker fills when they become active and hollows out when they are away.