Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Gravity Tags

A physics playground for tags — chips rain in from above, land as capsule bodies, pile up, and can be grabbed and thrown with real inertia.

MSource LandMellow UI

Why it stands out

A physics playground for tags — chips rain in from above, land as capsule bodies, pile up, and can be grabbed and thrown with real inertia.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/gravity-tags-demo.tsx

File content: "use client";

import React, { useEffect, useState } from "react";
import { GravityTags } from "../mellow/gravity-tags";

export const GRAVITY_TAGS = [
  "Motion",
  "Springs",
  "Canvas",
  "Typography",
  "Easing",
  "Physics",
  "Stagger",
  "Parallax",
  "Kinetic",
  "Editorial",
  "60fps",
  "Drag",
  "Inertia",
  "Blur",
  "Grain",
  "Reduced motion",
];


/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    const sync = () => setNarrow(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return narrow;
}

export default function GravityTagsDemo() {
  const [run, setRun] = useState(0);
  const narrow = useNarrow();

  return (
    <div className="flex w-full flex-col items-center gap-3 p-3 sm:gap-4 sm:p-6">
      <div className="w-full max-w-xl">
        <GravityTags
          key={run}
          tags={GRAVITY_TAGS}
          accentTags={["Motion"]}
          height={narrow ? 270 : 340}
        />
      </div>
      <div className="flex items-center gap-4">
        <button
          type="button"
          onClick={() => setRun((r) => r + 1)}
          className="cursor-pointer border border-[var(--rule)] px-2.5 py-1 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[var(--ink)] uppercase transition-colors hover:bg-[rgba(var(--ink-rgb),0.06)]"
        >
          Drop again
        </button>
        <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
          Grab a tag and throw it
        </p>
      </div>
    </div>
  );
}



File location: components/mellow/gravity-tags.tsx

File content: "use client";

import React, { useEffect, useRef, useState } from "react";

export interface GravityTagsProps {
  tags: string[];
  /** Tags rendered in the accent color. */
  accentTags?: string[];
  /** Playground height in px. */
  height?: number;
  /** Gravity in px/s². */
  gravity?: number;
  className?: string;
  style?: React.CSSProperties;
}

interface Body {
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Capsule half-length (pill body minus the round caps). */
  hl: number;
  /** Capsule radius — half the chip height. */
  r: number;
  el: HTMLDivElement;
  dragged: boolean;
}

/**
 * A physics playground for tags — chips rain in from above, land as capsule
 * bodies, pile up, and can be grabbed and thrown with real inertia.
 */
export function GravityTags({
  tags,
  accentTags = [],
  height = 360,
  gravity = 1800,
  className,
  style,
}: GravityTagsProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const chipRefs = useRef<(HTMLDivElement | null)[]>([]);
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    if (reduced) return;
    const container = containerRef.current;
    if (!container) return;

    let W = container.clientWidth;
    const H = height;

    const bodies: Body[] = [];
    chipRefs.current.slice(0, tags.length).forEach((el, i) => {
      if (!el) return;
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      bodies.push({
        x: 40 + ((i * 97) % Math.max(1, W - 80)),
        y: -h - i * 90,
        vx: ((((i * 37) % 100) / 100 - 0.5) * 120),
        vy: 0,
        hl: Math.max(0, (w - h) / 2),
        r: h / 2,
        el,
        dragged: false,
      });
    });

    let raf = 0;
    let last = performance.now();
    let idleFrames = 0;
    let asleep = false;
    const wake = () => {
      asleep = false;
      idleFrames = 0;
    };

    const ro = new ResizeObserver(() => {
      W = container.clientWidth;
      wake();
    });
    ro.observe(container);

    let dragBody: Body | null = null;
    const pointer = { x: 0, y: 0 };
    const toLocal = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      pointer.x = e.clientX - rect.left;
      pointer.y = e.clientY - rect.top;
    };
    const onDown = (e: PointerEvent) => {
      const body = bodies.find(
        (b) => b.el === e.target || b.el.contains(e.target as Node)
      );
      if (!body) return;
      dragBody = body;
      body.dragged = true;
      toLocal(e);
      try {
        container.setPointerCapture(e.pointerId);
      } catch {
        // pointer may already be gone (pen lift, synthetic events)
      }
      wake();
    };
    const onMove = (e: PointerEvent) => {
      if (!dragBody) return;
      toLocal(e);
      wake();
    };
    const onUp = () => {
      if (dragBody) {
        dragBody.dragged = false;
        dragBody = null;
      }
    };
    container.addEventListener("pointerdown", onDown);
    container.addEventListener("pointermove", onMove);
    container.addEventListener("pointerup", onUp);
    container.addEventListener("pointercancel", onUp);

    const closestOnSeg = (cx: number, half: number, px: number) =>
      cx + Math.max(-half, Math.min(half, px - cx));

    const step = (dt: number) => {
      for (const b of bodies) {
        if (b.dragged) {
          b.vx = (pointer.x - b.x) * 16;
          b.vy = (pointer.y - b.y) * 16;
        } else {
          b.vy += gravity * dt;
        }
        b.x += b.vx * dt;
        b.y += b.vy * dt;

        if (b.x - b.hl - b.r < 0) {
          b.x = b.hl + b.r;
          b.vx *= -0.4;
        }
        if (b.x + b.hl + b.r > W) {
          b.x = W - b.hl - b.r;
          b.vx *= -0.4;
        }
        if (b.y + b.r > H) {
          b.y = H - b.r;
          b.vy *= -0.35;
          b.vx *= 0.96;
        }
      }
      for (let i = 0; i < bodies.length; i++) {
        for (let j = i + 1; j < bodies.length; j++) {
          const a = bodies[i];
          const c = bodies[j];
          let p1 = closestOnSeg(a.x, a.hl, c.x);
          const p2 = closestOnSeg(c.x, c.hl, p1);
          p1 = closestOnSeg(a.x, a.hl, p2);
          let dx = p2 - p1;
          let dy = c.y - a.y;
          let d = Math.hypot(dx, dy);
          const minD = a.r + c.r;
          if (d >= minD) continue;
          if (d < 0.0001) {
            dx = 0;
            dy = 1;
            d = 1;
          }
          const nx = dx / d;
          const ny = dy / d;
          const pen = (minD - d) / 2;
          if (!a.dragged) {
            a.x -= nx * pen;
            a.y -= ny * pen;
          }
          if (!c.dragged) {
            c.x += nx * pen;
            c.y += ny * pen;
          }
          const rel = (c.vx - a.vx) * nx + (c.vy - a.vy) * ny;
          if (rel < 0) {
            const imp = (-(1 + 0.2) * rel) / 2;
            if (!a.dragged) {
              a.vx -= imp * nx;
              a.vy -= imp * ny;
            }
            if (!c.dragged) {
              c.vx += imp * nx;
              c.vy += imp * ny;
            }
          }
        }
      }
    };

    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      const dt = Math.min((now - last) / 1000, 0.033);
      last = now;
      if (asleep) return;

      const SUB = 3;
      for (let s = 0; s < SUB; s++) step(dt / SUB);

      let maxV = 0;
      for (const b of bodies) {
        maxV = Math.max(maxV, Math.abs(b.vx), Math.abs(b.vy));
        const tilt = Math.max(-8, Math.min(8, b.vx * 0.03));
        b.el.style.transform = `translate(${b.x - b.hl - b.r}px, ${
          b.y - b.r
        }px) rotate(${tilt}deg)`;
      }
      if (maxV < 3) idleFrames++;
      else idleFrames = 0;
      if (idleFrames > 60) asleep = true;
    };
    raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      container.removeEventListener("pointerdown", onDown);
      container.removeEventListener("pointermove", onMove);
      container.removeEventListener("pointerup", onUp);
      container.removeEventListener("pointercancel", onUp);
    };
  }, [reduced, height, gravity, tags]);

  const chipClass = (tag: string) =>
    [
      "whitespace-nowrap rounded-full border px-4 py-2 [font-family:var(--font-sans)] text-sm font-medium select-none",
      accentTags.includes(tag)
        ? "border-transparent bg-[oklch(0.65_0.25_250)] text-white"
        : "border-[var(--rule)] bg-[rgba(var(--ink-rgb),0.06)] text-[var(--ink)]",
    ].join(" ");

  if (reduced) {
    return (
      <div
        className={[
          "flex flex-wrap content-center items-center justify-center gap-2 rounded-lg border border-[var(--rule)] p-6",
          className,
        ]
          .filter(Boolean)
          .join(" ")}
        style={{ minHeight: height, ...style }}
      >
        {tags.map((tag, i) => (
          <div key={tag + i} className={chipClass(tag)}>
            {tag}
          </div>
        ))}
      </div>
    );
  }

  return (
    <div
      ref={containerRef}
      className={[
        "relative touch-none overflow-hidden rounded-lg border border-[var(--rule)] select-none",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ height, ...style }}
    >
      {tags.map((tag, i) => (
        <div
          key={tag + i}
          ref={(el) => {
            chipRefs.current[i] = el;
          }}
          className={
            "absolute top-0 left-0 cursor-grab will-change-transform active:cursor-grabbing " +
            chipClass(tag)
          }
          style={{ transform: "translate(-200%, -200%)" }}
        >
          {tag}
        </div>
      ))}
    </div>
  );
}

export default GravityTags;



Usage notes:
Add a GravityTags component from the mellow library — a physics playground for skill or feature tags. Chips rain in from above, collide as capsule bodies under a hand-rolled solver (no physics dependency), pile up at the floor, and can be grabbed and thrown with inertia. Pass `tags` as a string array, highlight a few with `accentTags`, and size the arena with `height`. Tune `gravity` for the fall feel. Renders a static wrapped tag list under prefers-reduced-motion.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI