All patterns

Embedding Cluster Settle

Scattered points drift into labelled groups, the halos and captions landing after them.

aifuturisticelegantautomatic · finite · intermediate · ~1.4s
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.

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

/**
 * Vibary · Embedding Cluster Settle
 *
 * A scatter of points drifting into the groups a model found. The
 * positions are generated from a fixed seed, so the same picture renders
 * on the server and on the client and the motion is reproducible.
 *
 * Self-contained: depends only on `react` and `motion`. The plot surface
 * is mixed from the inherited text color; the group hues are literal
 * because they are the legend.
 * Works with zero props; tune via `variant`, `groups`, `pointsPerGroup`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ClusterGroup = {
  /** Stable key, also the caption under the group. */
  label: string;
  /** Where the group settles, in plot coordinates. */
  x: number;
  y: number;
  /** Legend colour for the group. */
  color: string;
};

export type EmbeddingClusterSettleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Groups the points settle into. */
  groups?: ClusterGroup[];
  /** How many points each group gathers. */
  pointsPerGroup?: number;
  /** Plot size in px. */
  width?: number;
  height?: number;
  /** ms after mount before the points begin to gather. */
  leadInMs?: number;
  /** Caption while gathering / once settled. */
  workingLabel?: string;
  settledLabel?: string;
};

type VariantConfig = {
  /** Seconds between one point leaving the scatter and the next. */
  stagger: number;
  /** How far a point may sit from its group centre, in px. */
  spread: number;
  /** Point diameter in px. */
  dot: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: points are marks, not text, so they may travel and their
// halos may scale — but the spring still sits above a 0.8 damping ratio.
// A cluster whose members overshoot and swing back reads as unstable,
// which misrepresents what the picture is claiming. Group captions only
// fade.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Tight groups, almost simultaneous. For a small inline chart.
  subtle: {
    stagger: 0.006,
    spread: 18,
    dot: 5,
    spring: { type: "spring", stiffness: 320, damping: 29 },
  },
  // A readable gather with visible ordering. ζ ≈ 0.81 — the all-purpose
  // setting.
  default: {
    stagger: 0.012,
    spread: 25,
    dot: 6,
    spring: { type: "spring", stiffness: 260, damping: 26 },
  },
  // Looser groups and a longer cascade, for a full-width visualization.
  playful: {
    stagger: 0.023,
    spread: 33,
    dot: 7,
    spring: { type: "spring", stiffness: 180, damping: 22 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_GROUPS: ClusterGroup[] = [
  { label: "Billing", x: 66, y: 54, color: "#7C7CF0" },
  { label: "Delivery", x: 202, y: 46, color: "#3FA98B" },
  { label: "Returns", x: 132, y: 126, color: "#E08A3C" },
];

/** A tiny deterministic generator: the same scatter every render, on the
 *  server and in the browser, with nothing to hydrate around. */
function seeded(seed: number) {
  let state = seed;
  return () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
}

export default function EmbeddingClusterSettle({
  variant = "default",
  groups = DEFAULT_GROUPS,
  pointsPerGroup = 11,
  width = 296,
  height = 172,
  leadInMs = 460,
  workingLabel = "Grouping related tickets",
  settledLabel = "Groups found",
}: EmbeddingClusterSettleProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [gathered, setGathered] = useState(false);
  const settled = Boolean(reduceMotion) || gathered;

  useEffect(() => {
    if (reduceMotion) return;
    const timer = setTimeout(() => setGathered(true), leadInMs);
    return () => clearTimeout(timer);
  }, [reduceMotion, leadInMs]);

  const points = useMemo(() => {
    const random = seeded(20260818);
    const margin = cfg.dot;
    return groups.flatMap((group, groupIndex) =>
      Array.from({ length: pointsPerGroup }, (_, index) => {
        const angle = random() * Math.PI * 2;
        const radius = Math.sqrt(random()) * cfg.spread;
        return {
          key: `${group.label}-${index}`,
          color: group.color,
          order: index * groups.length + groupIndex,
          from: {
            x: margin + random() * (width - margin * 2),
            y: margin + random() * (height - margin * 2),
          },
          to: {
            x: group.x + Math.cos(angle) * radius,
            y: group.y + Math.sin(angle) * radius,
          },
        };
      })
    );
  }, [groups, pointsPerGroup, cfg.spread, cfg.dot, width, height]);

  return (
    <div style={{ width, color: "inherit" }}>
      <div
        aria-hidden
        style={{
          position: "relative",
          width,
          height,
          borderRadius: 12,
          border: `1px solid ${tone(11)}`,
          background: tone(4),
          overflow: "hidden",
        }}
      >
        {/* Halos land after the points do, so the grouping is read as a
            conclusion the picture reached rather than a frame it was
            drawn into. */}
        {groups.map((group, index) => (
          <motion.span
            key={`halo-${group.label}`}
            initial={reduceMotion ? false : { opacity: 0, scale: 0.88 }}
            animate={{ opacity: settled ? 1 : 0, scale: settled ? 1 : 0.88 }}
            transition={{
              duration: reduceMotion ? 0 : 0.4,
              delay: reduceMotion ? 0 : 0.24 + index * 0.05,
              ease: "easeOut",
            }}
            style={{
              position: "absolute",
              left: group.x - (cfg.spread + 14),
              top: group.y - (cfg.spread + 14),
              width: (cfg.spread + 14) * 2,
              height: (cfg.spread + 14) * 2,
              borderRadius: "50%",
              border: `1px dashed color-mix(in srgb, ${group.color} 42%, transparent)`,
              background: `color-mix(in srgb, ${group.color} 8%, transparent)`,
            }}
          />
        ))}

        {points.map((point) => (
          <motion.span
            key={point.key}
            initial={
              reduceMotion
                ? false
                : { x: point.from.x, y: point.from.y, opacity: 0.4 }
            }
            animate={{
              x: settled ? point.to.x : point.from.x,
              y: settled ? point.to.y : point.from.y,
              opacity: settled ? 1 : 0.4,
            }}
            transition={
              reduceMotion
                ? { duration: 0 }
                : {
                    ...cfg.spring,
                    delay: point.order * cfg.stagger,
                    opacity: { duration: 0.3, delay: point.order * cfg.stagger },
                  }
            }
            style={{
              position: "absolute",
              left: -cfg.dot / 2,
              top: -cfg.dot / 2,
              width: cfg.dot,
              height: cfg.dot,
              borderRadius: "50%",
              background: point.color,
            }}
          />
        ))}

        {groups.map((group, index) => {
          // A caption that would fall off the bottom of the plot goes
          // above its group instead of being clipped, so any set of
          // group positions stays readable.
          const below = group.y + cfg.spread + 16;
          const labelTop =
            below + 16 > height ? group.y - cfg.spread - 28 : below;
          return (
          <span
            key={`label-${group.label}`}
            style={{
              position: "absolute",
              left: group.x,
              top: labelTop,
              // Centering lives on a plain wrapper so the animated child
              // owns its transform outright.
              transform: "translateX(-50%)",
            }}
          >
            <motion.span
              // Captions are text: they fade, and never scale with the
              // halo they belong to.
              initial={reduceMotion ? false : { opacity: 0 }}
              animate={{ opacity: settled ? 1 : 0 }}
              transition={{
                duration: reduceMotion ? 0 : 0.3,
                delay: reduceMotion ? 0 : 0.34 + index * 0.05,
                ease: "easeOut",
              }}
              style={{
                display: "block",
                whiteSpace: "nowrap",
                fontSize: 10.5,
                fontWeight: 700,
                letterSpacing: 0.3,
                color: group.color,
              }}
            >
              {group.label}
            </motion.span>
          </span>
          );
        })}
      </div>

      <div
        role="status"
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          marginTop: 9,
          fontSize: 11,
          opacity: 0.55,
        }}
      >
        <span>
          {settled
            ? `${settledLabel}: ${groups.length}`
            : `${workingLabel} (${points.length})`}
        </span>
        <span style={{ marginLeft: "auto", display: "inline-flex", gap: 9 }}>
          {groups.map((group) => (
            <span
              key={group.label}
              style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
            >
              <span
                aria-hidden
                style={{
                  width: 6,
                  height: 6,
                  borderRadius: "50%",
                  background: group.color,
                }}
              />
              {group.label}
            </span>
          ))}
        </span>
      </div>
    </div>
  );
}

About this pattern

The moment a similarity map resolves. Points begin spread across the plot and travel to their group on a well-damped spring with a per-point cascade, so the eye can follow the gathering instead of being shown a finished picture. Halos and captions arrive only once the points have settled, which frames the grouping as a conclusion rather than a grid the dots were poured into. Positions come from a fixed seed, so the same scatter renders on the server and in the browser and the motion is reproducible.

Similarity map resolvingTopic grouping resultVector plot settlingSegmentation preview

Where it shows up

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

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Analytics view

    Embedding maps that animate points into their neighbourhoods.

Related patterns