Twelve circles arranged in a radial burst are each wrapped in GSAP Draggable with InertiaPlugin, so a flick carries momentum that decays naturally and bounces off the viewport bounds (edgeResistance 0.65). On drag each ball scales up to 1.15x; on release it springs back with elastic.out(1, 0.5). Bounds containment and inertia are independently toggleable. The entrance animation staggers from random with a back.out(2) overshoot.
Source
"use client";
import { useRef, useState, useEffect } from "react";
import gsap from "gsap";
import { Draggable } from "gsap/Draggable";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(Draggable, InertiaPlugin);
const BALL_COUNT = 12;
export default function DragPhysics() {
const containerRef = useRef<HTMLDivElement>(null);
const [dark, setDark] = useState(true);
const [boundsEnabled, setBoundsEnabled] = useState(true);
const [inertia, setInertia] = useState(true);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
const balls = gsap.utils.toArray<HTMLElement>(".drag-ball");
balls.forEach((ball, i) => {
const angle = (i / BALL_COUNT) * Math.PI * 2;
const radius = 120;
gsap.set(ball, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
Draggable.create(ball, {
type: "x,y",
bounds: boundsEnabled ? containerRef.current! : undefined,
inertia,
edgeResistance: 0.65,
onDrag() {
ball.style.transform += " scale(1.15)";
},
onDragEnd() {
gsap.to(ball, { scale: 1, duration: 0.3, ease: "elastic.out(1, 0.5)" });
},
});
});
gsap.from(balls, {
scale: 0,
opacity: 0,
stagger: { each: 0.05, from: "random" },
duration: 0.6,
ease: "back.out(2)",
});
}, containerRef);
return () => ctx.revert();
}, [boundsEnabled, inertia]);
return (
<div
ref={containerRef}
className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
style={{
background: dark ? "#0a0a0a" : "#f8f7f4",
transition: "background 0.4s ease",
}}
>
{Array.from({ length: BALL_COUNT }, (_, i) => {
const hue = (i * 30) % 360;
const size = 36 + (i % 4) * 12;
return (
<div
key={i}
className="drag-ball"
style={{
position: "absolute",
width: size,
height: size,
borderRadius: "50%",
background: `hsl(${hue}, 55%, ${dark ? 55 : 45}%)`,
boxShadow: `0 4px 20px hsla(${hue}, 55%, 40%, 0.3)`,
cursor: "grab",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2,
}}
>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 0",
fontSize: 10,
color: "#fff",
opacity: 0.7,
}}
>
{String(i + 1).padStart(2, "0")}
</span>
</div>
);
})}
{/* Controls */}
<div
style={{
position: "fixed", right: 24, top: "50%", transform: "translateY(-50%)", zIndex: 20, width: 200,
background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)", backdropFilter: "blur(12px)",
borderRadius: 16, padding: "20px 22px",
boxShadow: dark ? "0 2px 20px rgba(0,0,0,0.3)" : "0 2px 20px rgba(0,0,0,0.06)",
border: dark ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(0,0,0,0.06)", transition: "all 0.4s ease",
}}
>
<span style={{ fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 500, 'SRIF' 100", fontSize: 12, letterSpacing: "0.1em", textTransform: "uppercase", color: dark ? "#fff" : "#1a1a2e" }}>
Drag Physics
</span>
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
<button
onClick={() => setBoundsEnabled((v) => !v)}
style={{
fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11, letterSpacing: "0.06em", textAlign: "left", padding: "6px 10px", borderRadius: 8,
border: dark ? "1px solid rgba(255,255,255,0.15)" : "1px solid rgba(0,0,0,0.08)",
background: boundsEnabled ? (dark ? "rgba(255,255,255,0.1)" : "rgba(26,26,46,0.06)") : "transparent",
color: dark ? "#fff" : "#1a1a2e", cursor: "pointer", transition: "all 0.2s ease",
}}
>
Bounds {boundsEnabled ? "ON" : "OFF"}
</button>
<button
onClick={() => setInertia((v) => !v)}
style={{
fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11, letterSpacing: "0.06em", textAlign: "left", padding: "6px 10px", borderRadius: 8,
border: dark ? "1px solid rgba(255,255,255,0.15)" : "1px solid rgba(0,0,0,0.08)",
background: inertia ? (dark ? "rgba(255,255,255,0.1)" : "rgba(26,26,46,0.06)") : "transparent",
color: dark ? "#fff" : "#1a1a2e", cursor: "pointer", transition: "all 0.2s ease",
}}
>
Inertia {inertia ? "ON" : "OFF"}
</button>
</div>
<button onClick={() => setDark((v) => !v)} style={{ width: "100%", padding: "8px 0", marginTop: 16, borderRadius: 8, border: dark ? "1px solid rgba(255,255,255,0.2)" : "1px solid rgba(26,26,46,0.1)", background: dark ? "rgba(255,255,255,0.1)" : "rgba(26,26,46,0.04)", color: dark ? "#fff" : "#1a1a2e", fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 500, 'SRIF' 100", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase" as const, cursor: "pointer", transition: "all 0.3s ease" }}>
{dark ? "Dark" : "Light"}
</button>
</div>
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink from="DRAG PHYSICS" to="← LAB" href="/lab" className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors" style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }} />
</div>
</div>
);
}