# Gravity Tags (`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.

- **Docs:** https://www.mellowui.com/components/gravity-tags
- **Markdown:** https://www.mellowui.com/components/gravity-tags.md
- **Registry:** https://www.mellowui.com/r/gravity-tags.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/gravity-tags
- **Categories:** interactive, physics, animation
- **Dependencies:** none

## AI prompt

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.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/gravity-tags.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `tags` | `string[]` | — | The tag labels to drop in. |
| `accentTags` | `string[]` | `[]` | Tags rendered in the accent color. |
| `height` | `number` | `360` | Playground height in px. |
| `gravity` | `number` | `1800` | Gravity in px/s². |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"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;

```

## Demo

```tsx
"use client";

import React, { 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",
];

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

  return (
    <div className="flex w-full flex-col items-center gap-4 p-6">
      <div className="w-full max-w-xl">
        <GravityTags
          key={run}
          tags={GRAVITY_TAGS}
          accentTags={["Motion"]}
          height={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>
  );
}

```
