NEMAWASHI LAB

Drag Physics

GSAP

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.

01
02
03
04
05
06
07
08
09
10
11
12
Drag Physics

Source

DragPhysics.tsx156 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe