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 stack2. Install dependencies
npm install matter-jsRequired 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!