Experiments
A scratchpad for testing interactions, playing with physics, and pushing the boundaries of web components.
harshit
experiments
experiments
Gooey Mask Physics
A reactive SVG filter with spring physics and mouse repulsion.
Inspired by the hero section at schaum.cc
Code
"use client";
import React, { useEffect, useRef } from 'react';
interface GooeyMaskProps {
imageUrl?: string;
}
export function GooeyMask({
imageUrl = "https://images.unsplash.com/photo-1743710426934-89887ca897d8?q=80&w=1238&auto=format&fit=crop"
}: GooeyMaskProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<SVGCircleElement>(null);
const nodesRefs = useRef<(SVGCircleElement | null)[]>([]);
const requestRef = useRef<number>(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const MathPI2 = Math.PI * 2;
const CURSOR_RADIUS = 90;
const REPULSION_FORCE = 1.8;
const REPULSION_RADIUS = 300;
const SPRING_STRENGTH = 0.005;
const FRICTION = 0.88;
let width = container.clientWidth;
let height = container.clientHeight;
// Initial center
let mouseX = width / 2;
let mouseY = height / 2;
let cursorX = mouseX;
let cursorY = mouseY;
// Add a ResizeObserver to keep dimensions updated
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
height = entry.contentRect.height;
}
});
resizeObserver.observe(container);
const nodes = Array.from({ length: 8 }).map((_, i) => ({
el: nodesRefs.current[i],
x: width / 2 + (Math.random() - 0.5) * width * 1.5,
y: height / 2 + (Math.random() - 0.5) * height * 1.5,
vx: 0,
vy: 0,
baseRadius: i === 0 ? 240 : 100 + Math.random() * 120,
angle: Math.random() * MathPI2,
orbitSpeed: (Math.random() - 0.5) * 0.015,
spreadDistance: i === 0 ? 0 : 120 + Math.random() * 200,
pulseSpeed: 0.001 + Math.random() * 0.002,
}));
if (cursorRef.current) {
cursorRef.current.setAttribute('r', String(CURSOR_RADIUS));
}
const handleMouseMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
};
const handleTouchMove = (e: TouchEvent) => {
const rect = container.getBoundingClientRect();
mouseX = e.touches[0].clientX - rect.left;
mouseY = e.touches[0].clientY - rect.top;
};
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('touchmove', handleTouchMove, { passive: false });
// If mouse leaves the container, smoothly return to center
const handleMouseLeave = () => {
mouseX = width / 2;
mouseY = height / 2;
};
container.addEventListener('mouseleave', handleMouseLeave);
const animate = (time: number) => {
cursorX += (mouseX - cursorX) * 0.15;
cursorY += (mouseY - cursorY) * 0.15;
if (cursorRef.current) {
cursorRef.current.setAttribute('cx', String(cursorX));
cursorRef.current.setAttribute('cy', String(cursorY));
}
const globalTargetX = width / 2 +
Math.sin(time * 0.0003) * (width * 0.4) +
Math.cos(time * 0.0005) * (width * 0.15);
const globalTargetY = height / 2 +
Math.cos(time * 0.0004) * (height * 0.4) +
Math.sin(time * 0.0006) * (height * 0.15);
const timeSec = time * 0.001;
let spreadModifier = 1;
if (timeSec < 3) {
spreadModifier = 3.5 - timeSec * 0.9;
} else {
spreadModifier = 0.3 + (Math.sin((timeSec - 3) * 0.5) + 1) * 0.2;
}
nodes.forEach((node) => {
if (!node.el) return;
const currentRadius = node.baseRadius + Math.sin(time * node.pulseSpeed * 2) * 50;
node.angle += node.orbitSpeed;
const currentSpread = (node.spreadDistance + Math.sin(time * node.pulseSpeed) * 80) * spreadModifier;
const targetX = globalTargetX + Math.cos(node.angle) * currentSpread;
const targetY = globalTargetY + Math.sin(node.angle) * currentSpread;
node.vx += (targetX - node.x) * SPRING_STRENGTH;
node.vy += (targetY - node.y) * SPRING_STRENGTH;
const dx = node.x - cursorX;
const dy = node.y - cursorY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < currentRadius + REPULSION_RADIUS) {
const force = (currentRadius + REPULSION_RADIUS - dist) / (currentRadius + REPULSION_RADIUS);
node.vx += (dx / dist) * force * REPULSION_FORCE;
node.vy += (dy / dist) * force * REPULSION_FORCE;
}
node.vx *= FRICTION;
node.vy *= FRICTION;
node.x += node.vx;
node.y += node.vy;
node.el.setAttribute('cx', String(node.x));
node.el.setAttribute('cy', String(node.y));
node.el.setAttribute('r', String(currentRadius));
});
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('mouseleave', handleMouseLeave);
resizeObserver.disconnect();
if (requestRef.current) cancelAnimationFrame(requestRef.current);
};
}, []);
return (
<div ref={containerRef} className="w-full h-full relative overflow-hidden bg-[#EAE5D9]">
<svg
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
preserveAspectRatio="xMidYMid slice"
>
<defs>
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="40" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 65 -30" result="goo"
/>
</filter>
<mask id="blob-mask">
<g filter="url(#goo)">
{Array.from({ length: 8 }).map((_, i) => (
<circle
key={i}
ref={(el) => { nodesRefs.current[i] = el; }}
fill="white"
/>
))}
<circle ref={cursorRef} fill="white" />
</g>
</mask>
</defs>
<image
href={imageUrl}
width="100%"
height="100%"
preserveAspectRatio="xMidYMid slice"
mask="url(#blob-mask)"
/>
</svg>
</div>
);
}Tap to expand
HARSHIT
HellYeahh
Hover Me
Smooth Text Morph
A critically damped, scattered spring physics text morpher.
Code
"use client";
import React, { useState, useMemo } from "react";
import { motion } from "framer-motion";
interface CharData {
id: string;
char: string;
isSpace: boolean;
x: number;
y: number;
r: number;
}
interface SmoothTextMorphProps {
primaryText?: string;
secondaryText?: string;
}
const generateScatterData = (text: string): CharData[] => {
return text.split("").map((char, i) => {
if (char === " ") {
return { id: `space-${i}`, char, isSpace: true, x: 0, y: 0, r: 0 };
}
const angle = Math.random() * Math.PI * 2;
const distance = 100 + Math.random() * 100;
return {
id: `${char}-${i}`,
char,
isSpace: false,
x: Math.cos(angle) * distance,
y: Math.sin(angle) * distance - 20,
r: (Math.random() - 0.5) * 60,
};
});
};
export function SmoothTextMorph({
primaryText = "HARSHIT",
secondaryText = "Web Developer",
}: SmoothTextMorphProps) {
const [isHovered, setIsHovered] = useState(false);
const primaryCharData = useMemo(() => generateScatterData(primaryText), [primaryText]);
const secondaryCharData = useMemo(() => generateScatterData(secondaryText), [secondaryText]);
return (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center overflow-hidden bg-[#121212] font-['Plus_Jakarta_Sans',sans-serif] antialiased"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => setIsHovered(!isHovered)}
>
<div className="pointer-events-none absolute flex items-center justify-center">
{primaryCharData.map((item, i) => {
if (item.isSpace) {
return <span key={item.id} className="w-[0.4em]" />;
}
return (
<motion.span
key={item.id}
className="inline-block origin-center text-4xl md:text-6xl font-extrabold text-white tracking-[4px] will-change-transform"
initial={false}
animate={
isHovered
? { x: item.x, y: item.y, rotate: item.r, scale: 0.4, opacity: 0 }
: { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
}
transition={{
type: "spring",
bounce: 0,
duration: isHovered ? 0.7 : 0.9,
delay: isHovered ? i * 0.02 : 0.1 + i * 0.02,
}}
>
{item.char}
</motion.span>
);
})}
</div>
<div className="pointer-events-none absolute flex items-center justify-center">
{secondaryCharData.map((item, i) => {
if (item.isSpace) {
return <span key={item.id} className="w-[0.4em]" />;
}
return (
<motion.span
key={item.id}
className="inline-block origin-center text-3xl md:text-5xl font-medium text-zinc-400 tracking-tight will-change-transform pt-2"
initial={false}
animate={
isHovered
? { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
: { x: item.x, y: item.y, rotate: item.r, scale: 0.4, opacity: 0 }
}
transition={{
type: "spring",
bounce: 0,
duration: isHovered ? 0.9 : 0.6,
delay: isHovered ? 0.1 + i * 0.02 : i * 0.015,
}}
>
{item.char}
</motion.span>
);
})}
</div>
<motion.div
className="pointer-events-none absolute bottom-10 text-xs font-extrabold uppercase tracking-[2px] text-zinc-600"
animate={{ opacity: isHovered ? 0 : 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
Hover Me
</motion.div>
</div>
);
}Tap to expand
Swipe to explore
Ambient Deck
Tinder-style card swiper with ambient dynamic background lighting mapping dominant image colors.
Code
"use client";
import React, { useState, useEffect } from "react";
import { motion, PanInfo } from "framer-motion";
// --- Types & Data ---
interface CardData {
id: number;
url: string;
}
interface RGB {
r: number;
g: number;
b: number;
}
const initialCards: CardData[] = [
{ id: 1, url: "https://images.unsplash.com/photo-1485841890310-6a055c88698a?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80" },
{ id: 2, url: "https://images.unsplash.com/photo-1518791841217-8f162f1e1131?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80" },
{ id: 3, url: "https://images.unsplash.com/photo-1507146426996-ef05306b995a?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80" },
{ id: 4, url: "https://images.unsplash.com/photo-1511447333015-45b65e60f6d5?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80" }
];
// Cache to avoid recalculating colors for images we've already processed
const colorCache: Record<string, RGB> = {};
// Helper to extract the dominant ambient color from an image URL using a hidden Canvas
const getAverageColor = (url: string): Promise<RGB> => {
return new Promise((resolve) => {
if (colorCache[url]) return resolve(colorCache[url]);
const img = new Image();
img.crossOrigin = "Anonymous";
img.src = url;
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
if (!ctx) return resolve({ r: 50, g: 50, b: 80 });
ctx.drawImage(img, 0, 0, 64, 64);
const data = ctx.getImageData(0, 0, 64, 64).data;
let r = 0, g = 0, b = 0, count = 0;
// Sample every 16th pixel for performance
for (let i = 0; i < data.length; i += 16) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
const color = {
r: Math.round(r / count),
g: Math.round(g / count),
b: Math.round(b / count)
};
colorCache[url] = color;
resolve(color);
};
img.onerror = () => resolve({ r: 50, g: 50, b: 80 }); // Fallback on CORS error
});
};
export default function AmbientDeck() {
const [cards, setCards] = useState<CardData[]>(initialCards);
const [ambient1, setAmbient1] = useState<RGB>({ r: 56, g: 31, b: 122 });
const [ambient2, setAmbient2] = useState<RGB>({ r: 19, g: 78, b: 112 });
// Update ambient background colors whenever the top cards change
useEffect(() => {
const fetchColors = async () => {
const topColor = await getAverageColor(cards[0].url);
const nextColor = await getAverageColor(cards[1].url);
setAmbient1(topColor);
setAmbient2(nextColor);
};
fetchColors();
}, [cards]);
// Handle Drag logic
const handleDragEnd = (event: any, info: PanInfo) => {
const slideThreshold = 120; // How far to swipe before it shuffles
// If dragged far enough on the X axis, shuffle
if (Math.abs(info.offset.x) > slideThreshold) {
setCards((prev) => {
const newArray = [...prev];
const topCard = newArray.shift();
if (topCard) newArray.push(topCard); // Move top card to back
return newArray;
});
}
};
return (
<div
className="relative flex items-center justify-center h-full w-full overflow-hidden font-sans"
style={{ backgroundColor: "#09090b" }}
>
{/* Dynamic Ambient Background Layer */}
<motion.div
className="absolute inset-0 pointer-events-none"
animate={{
background: `
radial-gradient(circle at 20% 20%, rgba(${ambient1.r}, ${ambient1.g}, ${ambient1.b}, 0.5) 0%, transparent 60%),
radial-gradient(circle at 80% 80%, rgba(${ambient2.r}, ${ambient2.g}, ${ambient2.b}, 0.3) 0%, transparent 60%),
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.02) 0px, transparent 100%)
`
}}
transition={{ duration: 1.2, ease: "easeInOut" }}
/>
{/* Deck Container */}
<div className="relative w-[260px] h-[360px]">
{cards.map((card, index) => {
const isTop = index === 0;
// Modern layout variables
const xOffset = index * 8;
const yOffset = index * -10;
const scale = 1 - index * 0.04;
const rotate = index % 2 === 0 ? index * 2 : index * -2;
const zIndex = cards.length - index;
return (
<motion.div
key={card.id}
// "layout" prop tells Framer to smoothly animate array re-ordering
layout
className="absolute top-0 left-0 w-full h-full rounded-[24px] overflow-hidden"
style={{
zIndex,
transformOrigin: "center center",
backgroundColor: "#18181b",
border: "1px solid rgba(255, 255, 255, 0.15)",
boxShadow: `
0 30px 60px -12px rgba(0, 0, 0, 0.6),
0 18px 36px -18px rgba(0, 0, 0, 0.5),
inset 0 1px 1px rgba(255, 255, 255, 0.2)
`,
}}
// 1. Initial/Resting State
animate={{
x: xOffset,
y: yOffset,
scale: scale,
rotate: rotate,
}}
// Framer Motion Spring physics
transition={{
type: "spring",
stiffness: 300,
damping: 22,
bounce: 0.4,
}}
// 2. Drag Interactivity (Only applied to the top card)
drag={isTop ? "x" : false}
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }} // Snap back to origin
dragElastic={0.65} // "Bubble" edge resistance
onDragEnd={handleDragEnd}
// 3. Hover & Active States
whileHover={
isTop
? { y: -15, rotate: 0, scale: 1.02 }
: {}
}
whileTap={
isTop
? {
scale: 1.08, // Bubble pop feeling when grabbed
boxShadow: `
0 40px 70px -15px rgba(0,0,0,0.8),
inset 0 1px 1px rgba(255,255,255,0.3)
`,
}
: {}
}
>
<img
src={card.url}
alt={`Card ${card.id}`}
draggable={false} // Disable native HTML image dragging
className="w-full h-full object-cover pointer-events-none transition-all duration-300 ease-in-out hover:brightness-110 hover:contrast-110"
style={{
filter: isTop
? "brightness(1) contrast(1.1)"
: "brightness(0.85) contrast(1.1)",
}}
/>
</motion.div>
);
})}
{/* Minimalist modern hint */}
<div className="absolute -bottom-[70px] left-1/2 -translate-x-1/2 flex items-center gap-2 text-[rgba(255,255,255,0.4)] text-[0.85rem] font-medium tracking-[0.02em]">
<span>Swipe to explore</span>
</div>
</div>
</div>
);
}
Tap to expand