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, useMotionValue, useTransform } 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 });
// Motion values for Tinder-like swipe
const x = useMotionValue(0);
const rotateDrag = useTransform(x, [-200, 200], [-18, 18]);
const likeOpacity = useTransform(x, [20, 100], [0, 1]);
const nopeOpacity = useTransform(x, [-20, -100], [0, 1]);
// 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 = 100; // How far to swipe before it shuffles
// If dragged far enough on the X axis, shuffle
if (Math.abs(info.offset.x) > slideThreshold) {
x.set(0); // Instantly reset motion value so next top card starts at 0
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={{
x: isTop ? x : undefined,
rotate: isTop ? rotateDrag : undefined,
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: isTop ? 0 : xOffset,
y: yOffset,
scale: scale,
rotate: isTop ? 0 : 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 }} // Snap back to origin if not swiped hard enough
dragElastic={0.8} // Very elastic for Tinder drag feel
onDragEnd={handleDragEnd}
// 3. Hover & Active States
whileHover={
isTop
? { y: -15, scale: 1.02, cursor: "grab" }
: {}
}
whileTap={
isTop
? {
scale: 1.05,
cursor: "grabbing",
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)",
}}
/>
{/* Tinder Stamps */}
{isTop && (
<>
<motion.div
style={{ opacity: likeOpacity }}
className="absolute top-8 left-6 border-4 border-green-400 text-green-400 rounded-lg px-4 py-1 text-3xl font-black uppercase tracking-widest rotate-[-15deg] z-10 bg-black/20 backdrop-blur-sm shadow-[0_0_15px_rgba(74,222,128,0.2)]"
>
LIKE
</motion.div>
<motion.div
style={{ opacity: nopeOpacity }}
className="absolute top-8 right-6 border-4 border-red-500 text-red-500 rounded-lg px-4 py-1 text-3xl font-black uppercase tracking-widest rotate-[15deg] z-10 bg-black/20 backdrop-blur-sm shadow-[0_0_15px_rgba(239,68,68,0.2)]"
>
NOPE
</motion.div>
</>
)}
</motion.div>
);
})}
{/* Minimalist modern hint */}
<div className="absolute -bottom-[50px] 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
The New Standard for WebGL
A physics-driven fluid interface designed to blur the line between digital canvas and interactive reality.
Fluid Landing
A physics-driven fluid interface with WebGL and magnetic canvas interactions.
Code
"use client";
import React, { useEffect, useRef } from 'react';
// --- Types & Interfaces ---
interface MagnetState {
x: number;
y: number;
}
interface MouseState {
x: number;
y: number;
r: number;
}
// --- Physics Class ---
class PhysicsBlob {
x: number;
y: number;
vx: number;
vy: number;
r: number;
tx: number;
ty: number;
constructor(x: number, y: number, r: number) {
this.x = x;
this.y = y;
this.vx = 0;
this.vy = 0;
this.r = r;
// Idle floating anchors
this.tx = (Math.random() - 0.5) * 500;
this.ty = (Math.random() - 0.5) * 300;
}
update(
width: number,
height: number,
mouse: MouseState,
activeMagnet: MagnetState | null
) {
let cx, cy, pullStrength;
if (activeMagnet) {
cx = activeMagnet.x + (Math.random() - 0.5) * 30;
cy = activeMagnet.y + (Math.random() - 0.5) * 30;
pullStrength = 0.006;
} else {
cx = width / 2 + this.tx;
cy = height / 2 - 50 + this.ty;
pullStrength = 0.0006;
}
// Spring forces
this.vx += (cx - this.x) * pullStrength;
this.vy += (cy - this.y) * pullStrength;
// Mouse Pull
if (!activeMagnet) {
const dxM = mouse.x - this.x;
const dyM = mouse.y - this.y;
const distM = Math.sqrt(dxM * dxM + dyM * dyM);
if (distM < 400) {
this.vx += (dxM / distM) * 0.25;
this.vy += (dyM / distM) * 0.25;
}
}
// Friction
this.vx *= 0.92;
this.vy *= 0.92;
this.x += this.vx;
this.y += this.vy;
}
draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Component ---
export const FluidLanding: React.FC = () => {
const glCanvasRef = useRef<HTMLCanvasElement>(null);
const physicsCanvasRef = useRef<HTMLCanvasElement>(null);
// Mutable state held in refs to avoid React re-renders during the 60fps loop
const state = useRef({
width: 0,
height: 0,
mouse: { x: -1000, y: -1000, r: 80 } as MouseState,
targetMouse: { x: -1000, y: -1000, r: 80 } as MouseState,
activeMagnet: null as MagnetState | null,
blobs: [] as PhysicsBlob[],
});
// Event Handlers for Magnetic UI
const handleMagnetEnter = (e: React.MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
state.current.activeMagnet = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
state.current.targetMouse.r = 220;
};
const handleMagnetLeave = () => {
state.current.activeMagnet = null;
state.current.targetMouse.r = 80;
};
useEffect(() => {
const glCanvas = glCanvasRef.current;
const pCanvas = physicsCanvasRef.current;
if (!glCanvas || !pCanvas) return;
const gl = glCanvas.getContext('webgl2');
const ctx = pCanvas.getContext('2d');
if (!gl || !ctx) return;
let glFrameId: number;
let physicsFrameId: number;
// --- Interaction Listeners ---
const updateMouse = (x: number, y: number) => {
// Initialize if offscreen
if (state.current.mouse.x === -1000) {
state.current.mouse.x = x;
state.current.mouse.y = y;
}
state.current.targetMouse.x = x;
state.current.targetMouse.y = y;
};
const onMouseMove = (e: MouseEvent) => updateMouse(e.clientX, e.clientY);
const onTouchMove = (e: TouchEvent) =>
updateMouse(e.touches[0].clientX, e.touches[0].clientY);
const triggerShockwave = () => {
state.current.targetMouse.r = 30; // Shrink cursor sharply
const { mouse, blobs } = state.current;
blobs.forEach((b) => {
const dx = b.x - mouse.x;
const dy = b.y - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 600) {
const force = (600 - dist) * 0.2;
b.vx += (dx / dist) * force;
b.vy += (dy / dist) * force;
}
});
};
const restoreCursor = () => {
state.current.targetMouse.r = state.current.activeMagnet ? 220 : 80;
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove);
window.addEventListener('mousedown', triggerShockwave);
window.addEventListener('touchstart', triggerShockwave);
window.addEventListener('mouseup', restoreCursor);
window.addEventListener('touchend', restoreCursor);
// --- Initialization & Resizing ---
const initPhysics = () => {
const { width, height } = state.current;
state.current.blobs = Array.from({ length: 30 }, () => {
const r = 20 + Math.random() * 80;
return new PhysicsBlob(
width / 2 + (Math.random() - 0.5) * 300,
height / 2 + (Math.random() - 0.5) * 300,
r
);
});
};
const resize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
state.current.width = width;
state.current.height = height;
pCanvas.width = width;
pCanvas.height = height;
glCanvas.width = width;
glCanvas.height = height;
gl.viewport(0, 0, width, height);
if (resLoc) gl.uniform2f(resLoc, width, height);
initPhysics();
};
// --- WebGL Shader Setup ---
const vsSource = `#version 300 es
in vec4 aPosition;
void main() { gl_Position = aPosition; }
`;
const fsSource = `#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p); vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i + vec2(0.0,0.0)), hash(i + vec2(1.0,0.0)), u.x),
mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0,1.0)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0; float a = 0.5;
mat2 rot = mat2(cos(0.5), -sin(0.5), sin(0.5), cos(0.5));
for (int i = 0; i < 5; ++i) {
v += a * noise(p); p = rot * p * 2.0 + vec2(100.0); a *= 0.5;
}
return v;
}
void main() {
vec2 st = gl_FragCoord.xy / u_resolution.xy * 2.5;
st.x *= u_resolution.x / u_resolution.y;
vec2 m = u_mouse / u_resolution.xy;
m.y = 1.0 - m.y; m *= 2.5; m.x *= u_resolution.x / u_resolution.y;
float t = u_time * 0.25;
// Mouse Push Interaction
float distToMouse = distance(st, m);
st += normalize(st - m) * exp(-distToMouse * 3.0) * 0.3;
// Domain Warping
vec2 q = vec2(0.0);
q.x = fbm(st + vec2(0.0));
q.y = fbm(st + vec2(1.0));
vec2 r = vec2(0.0);
r.x = fbm(st + 1.0 * q + vec2(1.7, 9.2) + 0.15 * t);
r.y = fbm(st + 1.0 * q + vec2(8.3, 2.8) + 0.12 * t);
float f = fbm(st + r);
// Elevate Palette
vec3 colBg = vec3(0.02, 0.0, 0.05);
vec3 col1 = vec3(1.0, 0.15, 0.3);
vec3 col2 = vec3(0.0, 0.6, 1.0);
vec3 col3 = vec3(0.6, 0.0, 1.0);
vec3 color = mix(colBg, col1, clamp((f * f) * 2.5, 0.0, 1.0));
color = mix(color, col2, clamp(length(q) * 0.9, 0.0, 1.0));
color = mix(color, col3, clamp(length(r.x) * 1.8, 0.0, 1.0));
float mouseGlow = exp(-distToMouse * 3.5) * 1.0;
color += vec3(0.3, 0.6, 1.0) * mouseGlow;
vec2 center = gl_FragCoord.xy / u_resolution.xy - 0.5;
float vignette = 1.0 - dot(center, center) * 1.2;
vec3 finalColor = (f * f + 0.5 * f) * color * 2.5;
finalColor *= smoothstep(0.0, 1.0, vignette);
fragColor = vec4(finalColor, 1.0);
}
`;
const compileShader = (type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
};
const program = gl.createProgram();
const vShader = compileShader(gl.VERTEX_SHADER, vsSource);
const fShader = compileShader(gl.FRAGMENT_SHADER, fsSource);
if (!program || !vShader || !fShader) return;
gl.attachShader(program, vShader);
gl.attachShader(program, fShader);
gl.linkProgram(program);
gl.useProgram(program);
const posBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW
);
const posLoc = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
const timeLoc = gl.getUniformLocation(program, 'u_time');
const resLoc = gl.getUniformLocation(program, 'u_resolution');
const mouseLoc = gl.getUniformLocation(program, 'u_mouse');
window.addEventListener('resize', resize);
resize(); // Initial sizing
// --- Render Loops ---
const renderWebGL = (time: number) => {
gl.uniform1f(timeLoc, time * 0.001);
gl.uniform2f(mouseLoc, state.current.mouse.x, state.current.mouse.y);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
glFrameId = requestAnimationFrame(renderWebGL);
};
const clamp = (min: number, val: number, max: number) =>
Math.max(min, Math.min(max, val));
const renderPhysics = () => {
const { width, height, mouse, targetMouse, activeMagnet, blobs } = state.current;
mouse.x += (targetMouse.x - mouse.x) * 0.15;
mouse.y += (targetMouse.y - mouse.y) * 0.15;
mouse.r += (targetMouse.r - mouse.r) * 0.12;
// Trail Fade
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = `rgba(0, 0, 0, 0.25)`;
ctx.fillRect(0, 0, width, height);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'white';
// Typography
ctx.font = `800 ${clamp(80, width * 0.15, 250)}px 'Syne', sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.letterSpacing = '-0.04em';
ctx.fillText('SHAPE', width / 2, height / 2 - 60);
// Resolve soft collisions
for (let i = 0; i < blobs.length; i++) {
for (let j = i + 1; j < blobs.length; j++) {
const b1 = blobs[i];
const b2 = blobs[j];
const dx = b1.x - b2.x;
const dy = b1.y - b2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = b1.r + b2.r;
if (dist < minDist && dist > 0) {
const force = (minDist - dist) * 0.04;
const nx = dx / dist;
const ny = dy / dist;
b1.vx += nx * force;
b1.vy += ny * force;
b2.vx -= nx * force;
b2.vy -= ny * force;
}
}
}
blobs.forEach((blob) => {
blob.update(width, height, mouse, activeMagnet);
blob.draw(ctx);
});
// Draw active cursor
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, mouse.r, 0, Math.PI * 2);
ctx.fill();
physicsFrameId = requestAnimationFrame(renderPhysics);
};
glFrameId = requestAnimationFrame(renderWebGL);
physicsFrameId = requestAnimationFrame(renderPhysics);
// Cleanup
return () => {
cancelAnimationFrame(glFrameId);
cancelAnimationFrame(physicsFrameId);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('mousedown', triggerShockwave);
window.removeEventListener('touchstart', triggerShockwave);
window.removeEventListener('mouseup', restoreCursor);
window.removeEventListener('touchend', restoreCursor);
};
}, []);
return (
<div className="fluid-landing-container">
{/* Dynamic Styles Specific to the Component */}
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&family=Syne:wght@600;700;800&display=swap');
.fluid-landing-container {
--bg-color: #020203;
--text-light: #ffffff;
--text-muted: #888890;
--accent: rgba(255, 255, 255, 0.08);
--border: rgba(255, 255, 255, 0.12);
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: var(--bg-color);
font-family: 'Inter', sans-serif;
color: var(--text-light);
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.gl-canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 1;
}
.mask-container {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 2;
background-color: var(--bg-color);
mix-blend-mode: multiply;
pointer-events: none;
}
.physics-canvas {
width: 100%; height: 100%;
filter: url(#gooey-filter);
}
.texture-overlay {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%; z-index: 3;
pointer-events: none;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
background-size: 100px 100px, 100px 100px, 100px 100px;
background-position: center center;
opacity: 0.15;
mask-image: radial-gradient(circle at center, black 20%, transparent 80%);
-webkit-mask-image: radial-gradient(circle at center, black 20%, transparent 80%);
}
.ui-layer {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
z-index: 10;
display: flex; flex-direction: column;
pointer-events: none;
}
.interactive { pointer-events: auto; }
.landing-nav {
display: flex; justify-content: space-between; align-items: center;
padding: 2rem 4rem;
}
.landing-logo {
font-family: 'Syne', sans-serif;
font-weight: 800; font-size: 1.8rem;
letter-spacing: -1px; cursor: pointer;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.landing-logo:hover { transform: scale(1.08) rotate(-2deg); }
.landing-nav-links {
display: flex; gap: 2.5rem;
background: rgba(0,0,0,0.4);
padding: 0.75rem 2rem;
border-radius: 100px;
border: 1px solid var(--border);
backdrop-filter: blur(12px);
}
.landing-nav-links a {
color: var(--text-light); text-decoration: none;
font-size: 0.85rem; font-weight: 500;
opacity: 0.6; transition: all 0.3s;
text-transform: uppercase; letter-spacing: 1px;
}
.landing-nav-links a:hover { opacity: 1; transform: translateY(-1px); }
.landing-nav-btn {
background: #fff; color: #000;
border: none; padding: 0.75rem 1.8rem;
border-radius: 100px; font-weight: 600; font-size: 0.85rem;
cursor: pointer; transition: all 0.3s;
}
.landing-nav-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 255, 255, 0.2);
}
.landing-hero {
flex: 1; display: flex; flex-direction: column;
justify-content: space-between; align-items: center;
text-align: center; padding: 4rem 2rem;
}
.landing-badge {
background: var(--accent);
border: 1px solid var(--border);
padding: 0.5rem 1.2rem; border-radius: 50px;
font-size: 0.75rem; font-weight: 500;
letter-spacing: 2px; text-transform: uppercase;
backdrop-filter: blur(5px);
color: var(--text-muted);
}
.landing-hero-spacer { height: clamp(100px, 20vw, 300px); }
.landing-hero-copy {
max-width: 600px;
display: flex; flex-direction: column; align-items: center; gap: 2rem;
}
.landing-subtitle {
font-size: clamp(1rem, 1.5vw, 1.2rem);
font-weight: 300; color: var(--text-muted);
line-height: 1.6; margin: 0;
}
.landing-cta-btn {
background: transparent; color: #fff;
border: 1px solid rgba(255,255,255,0.3);
padding: 1.2rem 3rem; border-radius: 100px;
font-family: 'Syne', sans-serif;
font-size: 1.1rem; font-weight: 700;
cursor: pointer; position: relative; overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.landing-cta-btn:hover {
border-color: #fff;
background: rgba(255,255,255,0.1);
transform: scale(1.05);
letter-spacing: 2px;
}
.hidden-svg { position: absolute; width: 0; height: 0; pointer-events: none; }
`}</style>
{/* SVG Gooey Filter */}
<svg className="hidden-svg">
<defs>
<filter id="gooey-filter" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceGraphic" stdDeviation="25" 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 70 -30"
result="gooey"
/>
<feComposite in="SourceGraphic" in2="gooey" operator="atop" />
</filter>
</defs>
</svg>
{/* Canvases */}
<canvas ref={glCanvasRef} className="gl-canvas" />
<div className="mask-container">
<canvas ref={physicsCanvasRef} className="physics-canvas" />
</div>
<div className="texture-overlay" />
{/* User Interface */}
<div className="ui-layer">
<nav className="landing-nav">
<div
className="landing-logo interactive"
onMouseEnter={handleMagnetEnter}
onMouseLeave={handleMagnetLeave}
>
OASIS.
</div>
<div className="landing-nav-links interactive">
{['Engine', 'Showcase', 'Pricing'].map((item) => (
<a
key={item}
href={`#${item.toLowerCase()}`}
onMouseEnter={handleMagnetEnter}
onMouseLeave={handleMagnetLeave}
>
{item}
</a>
))}
</div>
<button
className="landing-nav-btn interactive"
onMouseEnter={handleMagnetEnter}
onMouseLeave={handleMagnetLeave}
>
Get Access
</button>
</nav>
<div className="landing-hero">
<div className="landing-badge">The New Standard for WebGL</div>
<div className="landing-hero-spacer" />
<div className="landing-hero-copy">
<p className="landing-subtitle">
A physics-driven fluid interface designed to blur the line between
digital canvas and interactive reality.
</p>
<button
className="landing-cta-btn interactive"
onMouseEnter={handleMagnetEnter}
onMouseLeave={handleMagnetLeave}
>
Deploy Now
</button>
</div>
</div>
</div>
</div>
);
};
export default FluidLanding;Tap to expand