All patterns

Presence Dot

The status dot eases between tints, settles once, and sends a single ring out on coming online.

socialsubtlefriendlyautomatic · finite · starter · ~3.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.

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

Online status on an avatarTeam member list presenceAway and offline statesAvailability in a directory

Where it shows up

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

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    A member's presence marker fills when they become active and hollows out when they are away.

Related patterns