Harshit

Experiments

A scratchpad for testing interactions, playing with physics, and pushing the boundaries of web components.

harshit
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
Card 1
Card 2
Card 3
Card 4
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