Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.
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 UIWhy 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.
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
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI