Skip to main content

Interaction

Stack

Physics-driven tech stack chips

Client · Matter.js

Live preview

Interact with the component below — same behavior as on the About page.

Loading preview…

Summary

An interactive stack showcase where each technology appears as a branded pill. Users can drag chips around a bounded canvas; gravity and collisions are handled by Matter.js while DOM nodes stay in sync via requestAnimationFrame.

  • Matter.js physics with chamfered rectangular bodies
  • Drag-and-drop via MouseConstraint with grab cursor states
  • ResizeObserver keeps floor and walls aligned on layout changes
  • Reset control re-mounts chips for a clean drop animation

Add to your project

Run the CLI to copy files into your project. Pick your language and styling below — the command updates automatically.

1. Add via CLI (recommended)

npx @shashank-portfolio/cli add stack

2. Install dependencies

npm install matter-js

Required packages

  • matter-js ^0.20.0
  • lucide-react ^0.562.0 · reset icon

3. Manual installation

Copy the file below and align the import path with your tsconfig alias (e.g. @/components/stack).

stack.tsx

"use client";

import { RotateCcw } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";

type Chip = {
  label: string;
  slug: string;
  bg: string;
  fg: string;
  iconUrl?: string;
};

const CHIPS: Chip[] = [
  {
    label: "Figma",
    slug: "figma",
    bg: "#1f1f1f",
    fg: "#ffffff",
    iconUrl: "https://cdn.simpleicons.org/figma",
  },
  {
    label: "React",
    slug: "react",
    bg: "#1FB6CB",
    fg: "#ffffff",
    iconUrl: "https://cdn.simpleicons.org/react",
  },
  {
    label: "Next.js",
    slug: "nextdotjs",
    bg: "#1f1f1f",
    fg: "#ffffff",
    iconUrl: "https://cdn.simpleicons.org/nextdotjs",
  },
  { label: "TypeScript", slug: "typescript", bg: "#2F74C0", fg: "#ffffff" },
  { label: "shadcn/ui", slug: "shadcnui", bg: "#5b54ff", fg: "#ffffff" },
  { label: "Cursor", slug: "cursor", bg: "#111111", fg: "#ffffff" },
  { label: "GSAP", slug: "gsap", bg: "#0AE448", fg: "#0a0a0a" },
  { label: "GitHub", slug: "github", bg: "#181717", fg: "#ffffff" },
  { label: "Vercel", slug: "vercel", bg: "#0a0a0a", fg: "#ffffff" },
  { label: "Tailwind CSS", slug: "tailwindcss", bg: "#2BBCF5", fg: "#ffffff" },
];

const CHIP_RADIUS = 14;
const ICON_RADIUS = 10;
const WALL_PAD = 16;

type ChipState = {
  chip: Chip;
  body: Matter.Body;
  width: number;
  height: number;
};

type StackSize = "default" | "wide" | "embed";

type StackProps = {
  showHeading?: boolean;
  /** Fills a card preview — matches parent radius, no nested border */
  embed?: boolean;
  size?: StackSize;
};

const STAGE_CLASS: Record<StackSize, string> = {
  default:
    "border-foreground/5 bg-foreground/2 dark:bg-foreground/5 relative h-40 w-full touch-none overflow-hidden rounded-4xl border sm:h-64",
  wide: "bg-foreground/2 dark:bg-foreground/5 relative h-52 w-full touch-none overflow-hidden rounded-4xl sm:h-72 md:h-80",
  embed:
    "bg-foreground/2 dark:bg-foreground/5 relative h-full w-full touch-none overflow-hidden rounded-2xl",
};

export function Stack({
  showHeading = true,
  embed = false,
  size: sizeProp,
}: StackProps): ReactNode {
  const size: StackSize = sizeProp ?? (embed ? "embed" : "default");
  const containerRef = useRef<HTMLDivElement | null>(null);
  const measureRef = useRef<HTMLDivElement | null>(null);
  const chipRefs = useRef<Array<HTMLDivElement | null>>([]);
  const [resetKey, setResetKey] = useState(0);

  useEffect(() => {
    const container = containerRef.current;
    const measure = measureRef.current;
    if (!container || !measure) return;

    let cancelled = false;
    let cleanup: (() => void) | undefined;

    void (async () => {
      const Matter = await import("matter-js");
      if (cancelled) return;

      const {
        Engine,
        Runner,
        World,
        Bodies,
        Body,
        Mouse,
        MouseConstraint,
        Events,
      } = Matter;

      const measureChildren = Array.from(measure.children) as HTMLElement[];
      const dims = measureChildren.map((el) => {
        const r = el.getBoundingClientRect();
        return { w: Math.max(80, r.width), h: Math.max(28, r.height) };
      });

      let width = container.clientWidth;
      let height = container.clientHeight;

      const engine = Engine.create();
      engine.gravity.y = 1;
      const world = engine.world;

      const wallThickness = 400;
      const floor = Bodies.rectangle(
        width / 2,
        height - WALL_PAD + wallThickness / 2,
        width * 3,
        wallThickness,
        { isStatic: true }
      );
      const leftWall = Bodies.rectangle(
        WALL_PAD - wallThickness / 2,
        height / 2,
        wallThickness,
        height * 4,
        { isStatic: true }
      );
      const rightWall = Bodies.rectangle(
        width - WALL_PAD + wallThickness / 2,
        height / 2,
        wallThickness,
        height * 4,
        { isStatic: true }
      );
      World.add(world, [floor, leftWall, rightWall]);

      const states: ChipState[] = CHIPS.map((chip, i) => {
        const dim = dims[i] ?? { w: 120, h: 36 };
        const { w, h } = dim;
        const halfW = w / 2;
        const minX = WALL_PAD + halfW + 4;
        const maxX = width - WALL_PAD - halfW - 4;
        const span = Math.max(1, maxX - minX);
        const slot = (i + 0.5) / CHIPS.length;
        const x =
          minX +
          slot * span +
          (Math.random() - 0.5) * Math.min(72, span * 0.12);
        const y = -80 - i * 60 - Math.random() * 120;
        const body = Bodies.rectangle(x, y, w, h, {
          chamfer: { radius: CHIP_RADIUS },
          restitution: 0.35,
          friction: 0.5,
          frictionAir: 0.025,
          density: 0.0018,
          angle: (Math.random() - 0.5) * 0.4,
        });
        World.add(world, body);
        return { chip, body, width: w, height: h };
      });

      const mouse = Mouse.create(container);

      const wheelTarget = mouse.element as HTMLElement & {
        mousewheel?: EventListener;
      };
      if (wheelTarget.mousewheel) {
        wheelTarget.removeEventListener("wheel", wheelTarget.mousewheel);
        wheelTarget.removeEventListener(
          "DOMMouseScroll",
          wheelTarget.mousewheel
        );
      }

      const mouseConstraint = MouseConstraint.create(engine, {
        mouse,
        constraint: {
          stiffness: 0.2,
          damping: 0.2,
          render: { visible: false },
        },
      });
      World.add(world, mouseConstraint);

      Events.on(mouseConstraint, "startdrag", () => {
        container.style.cursor = "grabbing";
      });
      Events.on(mouseConstraint, "enddrag", () => {
        container.style.cursor = "grab";
      });

      const runner = Runner.create();
      Runner.run(runner, engine);

      let tabVisible = true;
      let onScreen = true;

      const syncRunner = (): void => {
        if (tabVisible && onScreen) {
          Runner.run(runner, engine);
        } else {
          Runner.stop(runner);
        }
      };

      const onVisibility = (): void => {
        tabVisible = document.visibilityState === "visible";
        syncRunner();
      };
      document.addEventListener("visibilitychange", onVisibility);

      const visibilityIo = new IntersectionObserver(
        (entries) => {
          for (const e of entries) onScreen = e.isIntersecting;
          syncRunner();
        },
        { rootMargin: "100px" }
      );
      visibilityIo.observe(container);

      let raf = 0;
      const tick = (): void => {
        if (tabVisible && onScreen) {
          for (let i = 0; i < states.length; i++) {
            const s = states[i];
            const el = chipRefs.current[i];
            if (!s || !el) continue;
            const { x, y } = s.body.position;
            el.style.transform = `translate3d(${x - s.width / 2}px, ${y - s.height / 2}px, 0) rotate(${s.body.angle}rad)`;
          }
        }
        raf = requestAnimationFrame(tick);
      };
      raf = requestAnimationFrame(tick);

      const onResize = (): void => {
        const newW = container.clientWidth;
        const newH = container.clientHeight;
        if (newW === width && newH === height) return;
        Body.setPosition(floor, {
          x: newW / 2,
          y: newH - WALL_PAD + wallThickness / 2,
        });
        Body.setPosition(leftWall, {
          x: WALL_PAD - wallThickness / 2,
          y: newH / 2,
        });
        Body.setPosition(rightWall, {
          x: newW - WALL_PAD + wallThickness / 2,
          y: newH / 2,
        });
        width = newW;
        height = newH;
      };
      const ro = new ResizeObserver(onResize);
      ro.observe(container);

      cleanup = () => {
        cancelAnimationFrame(raf);
        document.removeEventListener("visibilitychange", onVisibility);
        visibilityIo.disconnect();
        ro.disconnect();
        Runner.stop(runner);
        World.clear(world, false);
        Engine.clear(engine);
      };
    })();

    return () => {
      cancelled = true;
      cleanup?.();
    };
  }, [resetKey]);

  return (
    <div
      className={
        embed ? "h-full w-full" : size === "wide" ? "w-full" : "flex flex-col gap-3"
      }
    >
      {showHeading ? (
        <div className="flex items-center gap-3">
          <h3 className="text-foreground text-[15px] font-semibold tracking-tight">
            Stack
          </h3>
        </div>
      ) : null}

      <div className={STAGE_CLASS[size]}>
        <button
          type="button"
          onClick={() => setResetKey((k) => k + 1)}
          aria-label="Reset stack"
          className="focus-ring border-foreground/8 bg-background text-foreground/70 hover:text-foreground absolute top-3 right-3 z-20 inline-flex h-9 w-9 items-center justify-center rounded-xl border transition-colors"
        >
          <RotateCcw
            className="h-4 w-4"
            strokeWidth={2.25}
            aria-hidden="true"
          />
        </button>

        <div
          ref={measureRef}
          aria-hidden="true"
          className="pointer-events-none invisible absolute top-0 left-0 flex flex-wrap gap-2"
        >
          {CHIPS.map((chip) => (
            <ChipPill key={`m-${chip.label}`} chip={chip} />
          ))}
        </div>

        <div
          ref={containerRef}
          className="absolute inset-0 cursor-grab select-none"
          style={{ touchAction: "none" }}
        >
          {CHIPS.map((chip, i) => (
            <div
              key={`${resetKey}-${chip.label}`}
              ref={(el) => {
                chipRefs.current[i] = el;
              }}
              data-stack-chip
              className="pointer-events-none absolute top-0 left-0 will-change-transform"
              style={{ transform: "translate3d(-9999px, -9999px, 0)" }}
            >
              <ChipPill chip={chip} />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function ChipPill({ chip }: { chip: Chip }): ReactNode {
  return (
    <div
      className="dark:ring-1 dark:ring-white/15 inline-flex items-center gap-2 p-1 pr-2 text-[15px] font-medium tracking-tight sm:text-[16px]"
      style={{
        backgroundColor: chip.bg,
        color: chip.fg,
        borderRadius: `${CHIP_RADIUS}px`,
      }}
    >
      <span
        className="inline-flex h-8 w-8 items-center justify-center bg-white/95"
        style={{ borderRadius: `${ICON_RADIUS}px` }}
        aria-hidden="true"
      >
        <img
          src={chip.iconUrl ?? `https://cdn.simpleicons.org/${chip.slug}`}
          alt=""
          width={18}
          height={18}
          className={`h-5 w-5 ${chip.label === "Next.js" ? "dark:invert" : ""}`}
          draggable={false}
        />
      </span>
      <span>{chip.label}</span>
    </div>
  );
}

4. Use in your app

import { Stack } from "@/components/about/stack";

export default function AboutSection() {
  return (
    <section>
      <Stack />
    </section>
  );
}

Let’s connect

I’m always open to discussing new projects, creative ideas, or opportunities to be part of your visions. Just reach out!

2026 © Built with Next.js

By Love ❤️