UI

Interactive Skill Cloud

A draggable physics pill cloud powered by matter.js, pills rain down, pile at the bottom, and collide; grab one and fling it.

Props

Height420px
Gravity

Installation

Interactive Skill Cloud is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/interactive-skill-cloud

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { InteractiveSkillCloud } from "@/components/ui/interactive-skill-cloud";
import { Code2, Zap } from "lucide-react";
 
const items = [
  { id: "react", name: "React", icon: <Code2 className="size-4" /> },
  { id: "zap", name: "tRPC", icon: <Zap className="size-4" /> },
];
 
export function TechStack() {
  return (
    <InteractiveSkillCloud
      items={items}
      height={360}
    />
  );
}

Props

PropTypeDefaultNotes
items RequiredSkillCloudItem[]-Array of { id, name, icon? }. The icon is any React node, so lucide, react-icons, or local SVGs all work.
heightnumber360Height of the physics arena in px.
gravitynumber11 drops the pills into a pile at the bottom of the arena. Set it to 0 for a float-and-throw playground where pills keep their momentum.
classNamestring—Extra classes forwarded to the container.
childrenReactNode—Optional overlay or background content rendered inside the arena.

Notes & Features

  • Real physics. Pills are rigid bodies via matter.js. On load they rain down and settle into a pile at the bottom; grab one with the mouse and fling it — it collides with the other pills and bounces off all four walls.
  • DOM stay crisp. Matter drives positions, angles, and velocities, but each pill is a real DOM node (rounded-full capsule with a solid border, inline icon + label). Transforms sync every engine tick, so text never rasterizes. Bodies are plain rectangles sized to each pill — stable for stacking — and the engine sleeps once the pile settles, so it rests fully still.
  • Alive on load. Pills get a small random shove and a slight initial rotation, so they tumble and bump as they fall, even before you touch them.
  • Reduced-motion safe. Under prefers-reduced-motion, and on the server, it renders a static wrapped cloud instead of running the physics loop.
  • Self-contained. Icons are passed as props — the component only imports matter.js and your icons.

Setup notes

TypeScript projects also want @types/matter-js. The physics loop runs on the client only, so the file is a client component; there is no server-side physics.

Manual installation

Rather not use the CLI? Everything Interactive Skill Cloud needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install matter-js

Source

components/ui/interactive-skill-cloud.tsx359 lines
"use client";

import { cn } from "@/lib/utils";
import Matter from "matter-js";
import {
  type RefObject,
  type ReactNode,
  useEffect,
  useRef,
  useSyncExternalStore,
} from "react";

export interface SkillCloudItem {
  id: string;
  name: string;
  /** Optional icon rendered inside the pill. Any React node works. */
  icon?: ReactNode;
}

export interface InteractiveSkillCloudProps {
  items: SkillCloudItem[];
  /** Height of the physics arena in px. */
  height?: number;
  /**
   * Whether pills fall or float. `1` (default) drops them into a pile at
   * the bottom of the arena. Set it to `0` for a float-and-throw playground
   * where pills keep their momentum after a fling.
   */
  gravity?: number;
  className?: string;
  /** Optional overlay or background content rendered inside the arena. */
  children?: ReactNode;
}

const FONT_STACK = "Geist Mono, ui-monospace, SFMono-Regular, monospace";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

function subscribeMotionPreference(callback: () => void) {
  const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
  mediaQuery.addEventListener("change", callback);
  return () => mediaQuery.removeEventListener("change", callback);
}

function getAnimationSnapshot() {
  return !window.matchMedia(REDUCED_MOTION_QUERY).matches;
}

function getAnimationServerSnapshot() {
  return false;
}

let sharedCtx: CanvasRenderingContext2D | null = null;
const measureCache = new Map<string, { width: number; height: number }>();

/** Width a pill needs for its icon + label, in px. */
function measureName(name: string): { width: number; height: number } {
  const cached = measureCache.get(name);
  if (cached) return cached;

  if (typeof document === "undefined") {
    return { width: 140, height: 34 };
  }

  if (!sharedCtx) {
    const canvas = document.createElement("canvas");
    sharedCtx = canvas.getContext("2d");
  }

  if (!sharedCtx) {
    return { width: 140, height: 34 };
  }

  sharedCtx.font = `600 12px ${FONT_STACK}`;
  const textWidth = Math.ceil(sharedCtx.measureText(name).width);
  const result = {
    // 16px icon + 12px gap + label + 2 * 12px padding
    width: Math.max(56, 16 + 12 + textWidth + 24),
    height: 34,
  };
  measureCache.set(name, result);
  return result;
}

function spawnPosition(width: number, height: number, falling: boolean) {
  return {
    x: 60 + Math.random() * Math.max(width - 120, 120),
    y: falling
      ? 30 + Math.random() * Math.max(height * 0.4, 60)
      : 40 + Math.random() * Math.max(height - 80, 80),
  };
}

export function InteractiveSkillCloud({
  items,
  height = 360,
  gravity = 1,
  className,
  children,
}: InteractiveSkillCloudProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  // Enable physics only on the client, and only when the user hasn't asked
  // for reduced motion. The static cloud is the SSR and a11y fallback.
  const animated = useSyncExternalStore(
    subscribeMotionPreference,
    getAnimationSnapshot,
    getAnimationServerSnapshot,
  );

  return (
    <div
      ref={containerRef}
      className={cn("relative w-full overflow-hidden", className)}
      style={{ height }}
    >
      {children}
      {!animated ? (
        <div className="absolute inset-0 flex flex-wrap content-start gap-2.5 p-4">
          {items.map((item) => (
            <span
              key={item.id}
              className="inline-flex items-center gap-2 rounded-full border bg-card px-3 py-1.5 font-mono text-xs font-medium text-foreground"
            >
              {item.icon}
              {item.name}
            </span>
          ))}
        </div>
      ) : null}

      {animated ? (
        <PhysicsLayer
          containerRef={containerRef}
          items={items}
          height={height}
          gravity={gravity}
        />
      ) : null}
    </div>
  );
}

interface PhysicsLayerProps {
  containerRef: RefObject<HTMLDivElement | null>;
  items: SkillCloudItem[];
  height: number;
  gravity: number;
}
function PhysicsLayer({
  containerRef,
  items,
  height,
  gravity,
}: PhysicsLayerProps) {
  const pointerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const container = containerRef.current;
    const pointer = pointerRef.current;
    if (!container || !pointer) {
      return;
    }

    const width = container.clientWidth || 640;

    const engine = Matter.Engine.create();
    engine.gravity.y = gravity;
    // Chamfered (capsule) bodies compile to compound bodies in matter-js and
    // oscillate when stacked, so physics runs on plain rectangles. Sleeping
    // plus extra solver iterations let the pile settle fully still.
    engine.enableSleeping = true;
    engine.positionIterations = 10;
    engine.velocityIterations = 8;
    const falling = gravity > 0;

    // One rigid body per pill. We store the DOM-measured size so the transform
    // math stays exact even when a body rotates (bounds inflate under tilt).
    const pills = items.map((item) => {
      const dims = measureName(item.name);
      const pos = spawnPosition(width, height, falling);
      const body = Matter.Bodies.rectangle(
        pos.x,
        pos.y,
        dims.width,
        dims.height,
        {
          restitution: falling ? 0.05 : 0.9,
          frictionAir: falling ? 0.015 : 0.04,
          friction: falling ? 0.6 : 0.02,
          angle: (Math.random() - 0.5) * 0.2,
          label: item.id,
        },
      );
      // A little shove so the cloud drifts and bumps on its own.
      Matter.Body.setVelocity(body, {
        x: (Math.random() - 0.5) * 1.5,
        y: (Math.random() - 0.5) * 1.5,
      });
      return { body, dims };
    });

    // Invisible walls keep pills inside the arena with a subtle inner padding.
    const wall = 64;
    const padding = 6;
    const walls = [
      Matter.Bodies.rectangle(width / 2, -wall / 2 + padding, width + wall * 2, wall, { isStatic: true }),
      Matter.Bodies.rectangle(width / 2, height + wall / 2 - padding, width + wall * 2, wall, { isStatic: true }),
      Matter.Bodies.rectangle(-wall / 2 + padding, height / 2, wall, height + wall * 2, { isStatic: true }),
      Matter.Bodies.rectangle(width + wall / 2 - padding, height / 2, wall, height + wall * 2, { isStatic: true }),
    ];

    Matter.Composite.add(engine.world, [
      ...pills.map((p) => p.body),
      ...walls,
    ]);

    // Mouse drag + fling. Matter's Mouse binds its own mousemove updates.
    const mouseConstraint = Matter.MouseConstraint.create(engine, {
      mouse: Matter.Mouse.create(pointer),
      constraint: {
        stiffness: 0.1,
        damping: 0.15,
        render: { visible: false },
      },
    });
    Matter.Composite.add(engine.world, mouseConstraint);

    // Keep pills upright and prevent upside-down text flipping.
    const uprightPills = () => {
      for (const { body } of pills) {
        let angle = body.angle % (2 * Math.PI);
        if (angle > Math.PI) angle -= 2 * Math.PI;
        if (angle < -Math.PI) angle += 2 * Math.PI;

        // Apply gentle restoring torque towards upright
        body.torque = -angle * 0.05;

        // Limit tilt angle to max ~45deg so pill labels are always readable
        const maxAngle = Math.PI / 4;
        if (Math.abs(angle) > maxAngle) {
          Matter.Body.setAngle(body, Math.sign(angle) * maxAngle);
          Matter.Body.setAngularVelocity(body, body.angularVelocity * 0.5);
        }
      }
    };

    // Cache DOM pill elements for fast 60fps transform updates
    const pillElementMap = new Map<string, HTMLElement>();
    for (const { body } of pills) {
      const el = container.querySelector<HTMLElement>(
        `[data-pill="${body.label}"]`,
      );
      if (el) pillElementMap.set(body.label, el);
    }

    // Move the DOM pills to match their bodies every engine tick.
    const syncPills = () => {
      uprightPills();
      for (const { body, dims } of pills) {
        const el =
          pillElementMap.get(body.label) ??
          container.querySelector<HTMLElement>(`[data-pill="${body.label}"]`);
        if (!el) continue;
        if (!pillElementMap.has(body.label)) {
          pillElementMap.set(body.label, el);
        }
        // Translate center of DOM pill to body.position and apply rotation
        el.style.transform = `translate3d(${body.position.x - dims.width / 2}px, ${body.position.y - dims.height / 2}px, 0) rotate(${body.angle}rad)`;
      }
    };

    // Initial position sync on mount
    syncPills();

    const handleResize = () => {
      const newWidth = container.clientWidth || 640;
      Matter.Body.setPosition(walls[0], { x: newWidth / 2, y: -wall / 2 + padding });
      Matter.Body.setPosition(walls[1], { x: newWidth / 2, y: height + wall / 2 - padding });
      Matter.Body.setPosition(walls[2], { x: -wall / 2 + padding, y: height / 2 });
      Matter.Body.setPosition(walls[3], { x: newWidth + wall / 2 - padding, y: height / 2 });
    };

    const ro = new ResizeObserver(handleResize);
    ro.observe(container);

    const runner = Matter.Runner.create();
    let isRunning = false;

    const startRunner = () => {
      if (!isRunning) {
        Matter.Runner.run(runner, engine);
        isRunning = true;
      }
    };

    const stopRunner = () => {
      if (isRunning) {
        Matter.Runner.stop(runner);
        isRunning = false;
      }
    };

    const io = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        if (entry?.isIntersecting) {
          startRunner();
        } else {
          stopRunner();
        }
      },
      { rootMargin: "100px" },
    );
    io.observe(container);

    Matter.Events.on(engine, "afterUpdate", syncPills);

    return () => {
      io.disconnect();
      ro.disconnect();
      Matter.Events.off(engine, "afterUpdate", syncPills);
      stopRunner();
      Matter.Composite.clear(engine.world, false);
      Matter.Engine.clear(engine);
    };
  }, [containerRef, items, height, gravity]);

  return (
    <div className="absolute inset-0">
      <div
        ref={pointerRef}
        className="absolute inset-0 z-10 h-full w-full cursor-grab active:cursor-grabbing"
      />
      {/* DOM pills — rendered here, moved by physics, inert to events.
          The overlay above them owns pointer input. */}
      <div className="pointer-events-none absolute inset-0 z-0">
        {items.map((item) => {
          const dims = measureName(item.name);
          return (
            <div
              key={item.id}
              data-pill={item.id}
              className="absolute left-0 top-0 flex items-center gap-2 rounded-full border bg-card px-3 py-1.5 font-mono text-xs font-medium text-foreground will-change-transform"
              style={{
                width: dims.width,
                height: dims.height,
                transformOrigin: "center",
                transform: "translate3d(-9999px, -9999px, 0)",
              }}
            >
              {item.icon}
              <span className="truncate">{item.name}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}