import FaultyTerminal from './FaultyTerminal';
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'; import React, { useEffect, useRef, useMemo, useCallback } from 'react';
type Vec2 = [number, number];
export interface FaultyTerminalProps extends React.HTMLAttributes { scale?: number; gridMul?: Vec2; digitSize?: number; timeScale?: number; pause?: boolean; scanlineIntensity?: number; glitchAmount?: number; flickerAmount?: number; noiseAmp?: number; chromaticAberration?: number; dither?: number | boolean; curvature?: number; tint?: string; mouseReact?: boolean; mouseStrength?: number; dpr?: number; pageLoadAnimation?: boolean; brightness?: number; }
const vertexShader = attribute vec2 position; attribute vec2 uv; varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); };
const fragmentShader = ` precision mediump float;
varying vec2 vUv;
uniform float iTime; uniform vec3 iResolution; uniform float uScale;
uniform vec2 uGridMul; uniform float uDigitSize; uniform float uScanlineIntensity; uniform float uGlitchAmount; uniform float uFlickerAmount; uniform float uNoiseAmp; uniform float uChromaticAberration; uniform float uDither; uniform float uCurvature; uniform vec3 uTint; uniform vec2 uMouse; uniform float uMouseStrength; uniform float uUseMouse; uniform float uPageLoadProgress; uniform float uUsePageLoadAnimation; uniform float uBrightness;
float time;
float hash21(vec2 p){ p = fract(p * 234.56); p += dot(p, p + 34.56); return fract(p.x * p.y); }
float noise(vec2 p) { return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2; }
mat2 rotate(float angle) { float c = cos(angle); float s = sin(angle); return mat2(c, -s, s, c); }
float fbm(vec2 p) { p *= 1.1; float f = 0.0; float amp = 0.5 * uNoiseAmp;
mat2 modify0 = rotate(time * 0.02); f += amp * noise(p); p = modify0 * p * 2.0; amp *= 0.454545;
mat2 modify1 = rotate(time * 0.02); f += amp * noise(p); p = modify1 * p * 2.0; amp *= 0.454545;
mat2 modify2 = rotate(time * 0.08); f += amp * noise(p);
return f; }
float pattern(vec2 p, out vec2 q, out vec2 r) { vec2 offset1 = vec2(1.0); vec2 offset0 = vec2(0.0); mat2 rot01 = rotate(0.1 * time); mat2 rot1 = rotate(0.1);
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1)); r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0)); return fbm(p + r); }
float digit(vec2 p){ vec2 grid = uGridMul * 15.0; vec2 s = floor(p * grid) / grid; p = p * grid; vec2 q, r; float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
if(uUseMouse > 0.5){
vec2 mouseWorld = uMouse * uScale;
float distToMouse = distance(s, mouseWorld);
float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
intensity += mouseInfluence;
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
intensity += ripple;
}
if(uUsePageLoadAnimation > 0.5){
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
float cellDelay = cellRandom * 0.8;
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
intensity *= fadeAlpha;
}
p = fract(p);
p *= uDigitSize;
float px5 = p.x * 5.0;
float py5 = (1.0 - p.y) * 5.0;
float x = fract(px5);
float y = fract(py5);
float i = floor(py5) - 2.0;
float j = floor(px5) - 2.0;
float n = i * i + j * j;
float f = n * 0.0625;
float isOn = step(0.1, intensity - f);
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
}
float onOff(float a, float b, float c) { return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount; }
float displace(vec2 look) { float y = look.y - mod(iTime * 0.25, 1.0); float window = 1.0 / (1.0 + 50.0 * y * y); return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window; }
vec3 getColor(vec2 p){
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
bar *= uScanlineIntensity;
float displacement = displace(p);
p.x += displacement;
if (uGlitchAmount != 1.0) {
float extra = displacement * (uGlitchAmount - 1.0);
p.x += extra;
}
float middle = digit(p);
const float off = 0.002;
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
return baseColor;
}
vec2 barrel(vec2 uv){ vec2 c = uv * 2.0 - 1.0; float r2 = dot(c, c); c *= 1.0 + uCurvature * r2; return c * 0.5 + 0.5; }
void main() { time = iTime * 0.333333; vec2 uv = vUv;
if(uCurvature != 0.0){
uv = barrel(uv);
}
vec2 p = uv * uScale;
vec3 col = getColor(p);
if(uChromaticAberration != 0.0){
vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
col.r = getColor(p + ca).r;
col.b = getColor(p - ca).b;
}
col *= uTint;
col *= uBrightness;
if(uDither > 0.0){
float rnd = hash21(gl_FragCoord.xy);
col += (rnd - 0.5) * (uDither * 0.003922);
}
gl_FragColor = vec4(col, 1.0);
} `;
function hexToRgb(hex: string): [number, number, number] { let h = hex.replace('#', '').trim(); if (h.length === 3) h = h .split('') .map(c => c + c) .join(''); const num = parseInt(h, 16); return [((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255]; }
export default function FaultyTerminal({ scale = 1, gridMul = [2, 1], digitSize = 1.5, timeScale = 0.3, pause = false, scanlineIntensity = 0.3, glitchAmount = 1, flickerAmount = 1, noiseAmp = 1, chromaticAberration = 0, dither = 0, curvature = 0.2, tint = '#ffffff', mouseReact = true, mouseStrength = 0.2, dpr = Math.min(window.devicePixelRatio || 1, 2), pageLoadAnimation = true, brightness = 1, className, style, ...rest }: FaultyTerminalProps) { const containerRef = useRef(null); const programRef = useRef(null); const rendererRef = useRef(null); const mouseRef = useRef({ x: 0.5, y: 0.5 }); const smoothMouseRef = useRef({ x: 0.5, y: 0.5 }); const frozenTimeRef = useRef(0); const rafRef = useRef(0); const loadAnimationStartRef = useRef(0); const timeOffsetRef = useRef(Math.random() * 100);
const tintVec = useMemo(() => hexToRgb(tint), [tint]);
const ditherValue = useMemo(() => (typeof dither === 'boolean' ? (dither ? 1 : 0) : dither), [dither]);
const handleMouseMove = useCallback((e: MouseEvent) => { const ctn = containerRef.current; if (!ctn) return; const rect = ctn.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = 1 - (e.clientY - rect.top) / rect.height; mouseRef.current = { x, y }; }, []);
useEffect(() => { const ctn = containerRef.current; if (!ctn) return;
const renderer = new Renderer({ dpr });
rendererRef.current = renderer;
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 1);
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
uScale: { value: scale },
uGridMul: { value: new Float32Array(gridMul) },
uDigitSize: { value: digitSize },
uScanlineIntensity: { value: scanlineIntensity },
uGlitchAmount: { value: glitchAmount },
uFlickerAmount: { value: flickerAmount },
uNoiseAmp: { value: noiseAmp },
uChromaticAberration: { value: chromaticAberration },
uDither: { value: ditherValue },
uCurvature: { value: curvature },
uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) },
uMouse: {
value: new Float32Array([smoothMouseRef.current.x, smoothMouseRef.current.y])
},
uMouseStrength: { value: mouseStrength },
uUseMouse: { value: mouseReact ? 1 : 0 },
uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 },
uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 },
uBrightness: { value: brightness }
}
});
programRef.current = program;
const mesh = new Mesh(gl, { geometry, program });
function resize() {
if (!ctn || !renderer) return;
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
program.uniforms.iResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
);
}
const resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(ctn);
resize();
const update = (t: number) => {
rafRef.current = requestAnimationFrame(update);
if (pageLoadAnimation && loadAnimationStartRef.current === 0) {
loadAnimationStartRef.current = t;
}
if (!pause) {
const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale;
program.uniforms.iTime.value = elapsed;
frozenTimeRef.current = elapsed;
} else {
program.uniforms.iTime.value = frozenTimeRef.current;
}
if (pageLoadAnimation && loadAnimationStartRef.current > 0) {
const animationDuration = 2000;
const animationElapsed = t - loadAnimationStartRef.current;
const progress = Math.min(animationElapsed / animationDuration, 1);
program.uniforms.uPageLoadProgress.value = progress;
}
if (mouseReact) {
const dampingFactor = 0.08;
const smoothMouse = smoothMouseRef.current;
const mouse = mouseRef.current;
smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
const mouseUniform = program.uniforms.uMouse.value as Float32Array;
mouseUniform[0] = smoothMouse.x;
mouseUniform[1] = smoothMouse.y;
}
renderer.render({ scene: mesh });
};
rafRef.current = requestAnimationFrame(update);
ctn.appendChild(gl.canvas);
if (mouseReact) ctn.addEventListener('mousemove', handleMouseMove);
return () => {
cancelAnimationFrame(rafRef.current);
resizeObserver.disconnect();
if (mouseReact) ctn.removeEventListener('mousemove', handleMouseMove);
if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
gl.getExtension('WEBGL_lose_context')?.loseContext();
loadAnimationStartRef.current = 0;
timeOffsetRef.current = Math.random() * 100;
};
}, [ dpr, pause, timeScale, scale, gridMul, digitSize, scanlineIntensity, glitchAmount, flickerAmount, noiseAmp, chromaticAberration, ditherValue, curvature, tintVec, mouseReact, mouseStrength, pageLoadAnimation, brightness, handleMouseMove ]);
return (
<div ref={containerRef} className={w-full h-full relative overflow-hidden ${className}} style={style} {...rest} />
);
}
import LiquidEther from './LiquidEther';
import React, { useEffect, useRef } from 'react'; import * as THREE from 'three';
export interface LiquidEtherProps { mouseForce?: number; cursorSize?: number; isViscous?: boolean; viscous?: number; iterationsViscous?: number; iterationsPoisson?: number; dt?: number; BFECC?: boolean; resolution?: number; isBounce?: boolean; colors?: string[]; style?: React.CSSProperties; className?: string; autoDemo?: boolean; autoSpeed?: number; autoIntensity?: number; takeoverDuration?: number; autoResumeDelay?: number; autoRampDuration?: number; }
interface SimOptions { iterations_poisson: number; iterations_viscous: number; mouse_force: number; resolution: number; cursor_size: number; viscous: number; isBounce: boolean; dt: number; isViscous: boolean; BFECC: boolean; }
interface LiquidEtherWebGL { output?: { simulation?: { options: SimOptions; resize: () => void } }; autoDriver?: { enabled: boolean; speed: number; resumeDelay: number; rampDurationMs: number; mouse?: { autoIntensity: number; takeoverDuration: number }; forceStop: () => void; }; resize: () => void; start: () => void; pause: () => void; dispose: () => void; }
const defaultColors = ['#5227FF', '#FF9FFC', '#B19EEF'];
export default function LiquidEther({ mouseForce = 20, cursorSize = 100, isViscous = false, viscous = 30, iterationsViscous = 32, iterationsPoisson = 32, dt = 0.014, BFECC = true, resolution = 0.5, isBounce = false, colors = defaultColors, style = {}, className = '', autoDemo = true, autoSpeed = 0.5, autoIntensity = 2.2, takeoverDuration = 0.25, autoResumeDelay = 1000, autoRampDuration = 0.6 }: LiquidEtherProps): React.ReactElement { const mountRef = useRef<HTMLDivElement | null>(null); const webglRef = useRef<LiquidEtherWebGL | null>(null); const resizeObserverRef = useRef<ResizeObserver | null>(null); const rafRef = useRef<number | null>(null); const intersectionObserverRef = useRef<IntersectionObserver | null>(null); const isVisibleRef = useRef(true); const resizeRafRef = useRef<number | null>(null);
useEffect(() => { if (!mountRef.current) return;
function makePaletteTexture(stops: string[]): THREE.DataTexture {
let arr: string[];
if (Array.isArray(stops) && stops.length > 0) {
arr = stops.length === 1 ? [stops[0], stops[0]] : stops;
} else {
arr = ['#ffffff', '#ffffff'];
}
const w = arr.length;
const data = new Uint8Array(w * 4);
for (let i = 0; i < w; i++) {
const c = new THREE.Color(arr[i]);
data[i * 4 + 0] = Math.round(c.r * 255);
data[i * 4 + 1] = Math.round(c.g * 255);
data[i * 4 + 2] = Math.round(c.b * 255);
data[i * 4 + 3] = 255;
}
const tex = new THREE.DataTexture(data, w, 1, THREE.RGBAFormat);
tex.magFilter = THREE.LinearFilter;
tex.minFilter = THREE.LinearFilter;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
return tex;
}
const paletteTex = makePaletteTexture(colors);
// Hard-code transparent background vector (alpha 0)
const bgVec4 = new THREE.Vector4(0, 0, 0, 0);
class CommonClass {
width = 0;
height = 0;
aspect = 1;
pixelRatio = 1;
isMobile = false;
breakpoint = 768;
fboWidth: number | null = null;
fboHeight: number | null = null;
time = 0;
delta = 0;
container: HTMLElement | null = null;
renderer: THREE.WebGLRenderer | null = null;
clock: THREE.Clock | null = null;
init(container: HTMLElement) {
this.container = container;
this.pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
this.resize();
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Always transparent
this.renderer.autoClear = false;
this.renderer.setClearColor(new THREE.Color(0x000000), 0);
this.renderer.setPixelRatio(this.pixelRatio);
this.renderer.setSize(this.width, this.height);
const el = this.renderer.domElement;
el.style.width = '100%';
el.style.height = '100%';
el.style.display = 'block';
this.clock = new THREE.Clock();
this.clock.start();
}
resize() {
if (!this.container) return;
const rect = this.container.getBoundingClientRect();
this.width = Math.max(1, Math.floor(rect.width));
this.height = Math.max(1, Math.floor(rect.height));
this.aspect = this.width / this.height;
if (this.renderer) this.renderer.setSize(this.width, this.height, false);
}
update() {
if (!this.clock) return;
this.delta = this.clock.getDelta();
this.time += this.delta;
}
}
const Common = new CommonClass();
class MouseClass {
mouseMoved = false;
coords = new THREE.Vector2();
coords_old = new THREE.Vector2();
diff = new THREE.Vector2();
timer: number | null = null;
container: HTMLElement | null = null;
docTarget: Document | null = null;
listenerTarget: Window | null = null;
isHoverInside = false;
hasUserControl = false;
isAutoActive = false;
autoIntensity = 2.0;
takeoverActive = false;
takeoverStartTime = 0;
takeoverDuration = 0.25;
takeoverFrom = new THREE.Vector2();
takeoverTo = new THREE.Vector2();
onInteract: (() => void) | null = null;
private _onMouseMove = this.onDocumentMouseMove.bind(this);
private _onTouchStart = this.onDocumentTouchStart.bind(this);
private _onTouchMove = this.onDocumentTouchMove.bind(this);
private _onTouchEnd = this.onTouchEnd.bind(this);
private _onDocumentLeave = this.onDocumentLeave.bind(this);
init(container: HTMLElement) {
this.container = container;
this.docTarget = container.ownerDocument || null;
const defaultView = this.docTarget?.defaultView || (typeof window !== 'undefined' ? window : null);
if (!defaultView) return;
this.listenerTarget = defaultView;
this.listenerTarget.addEventListener('mousemove', this._onMouseMove);
this.listenerTarget.addEventListener('touchstart', this._onTouchStart, {
passive: true
});
this.listenerTarget.addEventListener('touchmove', this._onTouchMove, {
passive: true
});
this.listenerTarget.addEventListener('touchend', this._onTouchEnd);
this.docTarget?.addEventListener('mouseleave', this._onDocumentLeave);
}
dispose() {
if (this.listenerTarget) {
this.listenerTarget.removeEventListener('mousemove', this._onMouseMove);
this.listenerTarget.removeEventListener('touchstart', this._onTouchStart);
this.listenerTarget.removeEventListener('touchmove', this._onTouchMove);
this.listenerTarget.removeEventListener('touchend', this._onTouchEnd);
}
if (this.docTarget) {
this.docTarget.removeEventListener('mouseleave', this._onDocumentLeave);
}
this.listenerTarget = null;
this.docTarget = null;
this.container = null;
}
private isPointInside(clientX: number, clientY: number) {
if (!this.container) return false;
const rect = this.container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}
private updateHoverState(clientX: number, clientY: number) {
this.isHoverInside = this.isPointInside(clientX, clientY);
return this.isHoverInside;
}
setCoords(x: number, y: number) {
if (!this.container) return;
if (this.timer) window.clearTimeout(this.timer);
const rect = this.container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const nx = (x - rect.left) / rect.width;
const ny = (y - rect.top) / rect.height;
this.coords.set(nx * 2 - 1, -(ny * 2 - 1));
this.mouseMoved = true;
this.timer = window.setTimeout(() => {
this.mouseMoved = false;
}, 100);
}
setNormalized(nx: number, ny: number) {
this.coords.set(nx, ny);
this.mouseMoved = true;
}
onDocumentMouseMove(event: MouseEvent) {
if (!this.updateHoverState(event.clientX, event.clientY)) return;
if (this.onInteract) this.onInteract();
if (this.isAutoActive && !this.hasUserControl && !this.takeoverActive) {
if (!this.container) return;
const rect = this.container.getBoundingClientRect();
const nx = (event.clientX - rect.left) / rect.width;
const ny = (event.clientY - rect.top) / rect.height;
this.takeoverFrom.copy(this.coords);
this.takeoverTo.set(nx * 2 - 1, -(ny * 2 - 1));
this.takeoverStartTime = performance.now();
this.takeoverActive = true;
this.hasUserControl = true;
this.isAutoActive = false;
return;
}
this.setCoords(event.clientX, event.clientY);
this.hasUserControl = true;
}
onDocumentTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) return;
const t = event.touches[0];
if (!this.updateHoverState(t.clientX, t.clientY)) return;
if (this.onInteract) this.onInteract();
this.setCoords(t.clientX, t.clientY);
this.hasUserControl = true;
}
onDocumentTouchMove(event: TouchEvent) {
if (event.touches.length !== 1) return;
const t = event.touches[0];
if (!this.updateHoverState(t.clientX, t.clientY)) return;
if (this.onInteract) this.onInteract();
this.setCoords(t.clientX, t.clientY);
}
onTouchEnd() {
this.isHoverInside = false;
}
onDocumentLeave() {
this.isHoverInside = false;
}
update() {
if (this.takeoverActive) {
const t = (performance.now() - this.takeoverStartTime) / (this.takeoverDuration * 1000);
if (t >= 1) {
this.takeoverActive = false;
this.coords.copy(this.takeoverTo);
this.coords_old.copy(this.coords);
this.diff.set(0, 0);
} else {
const k = t * t * (3 - 2 * t);
this.coords.copy(this.takeoverFrom).lerp(this.takeoverTo, k);
}
}
this.diff.subVectors(this.coords, this.coords_old);
this.coords_old.copy(this.coords);
if (this.coords_old.x === 0 && this.coords_old.y === 0) this.diff.set(0, 0);
if (this.isAutoActive && !this.takeoverActive) this.diff.multiplyScalar(this.autoIntensity);
}
}
const Mouse = new MouseClass();
class AutoDriver {
mouse: MouseClass;
manager: WebGLManager;
enabled: boolean;
speed: number;
resumeDelay: number;
rampDurationMs: number;
active = false;
current = new THREE.Vector2(0, 0);
target = new THREE.Vector2();
lastTime = performance.now();
activationTime = 0;
margin = 0.2;
private _tmpDir = new THREE.Vector2();
constructor(
mouse: MouseClass,
manager: WebGLManager,
opts: { enabled: boolean; speed: number; resumeDelay: number; rampDuration: number }
) {
this.mouse = mouse;
this.manager = manager;
this.enabled = opts.enabled;
this.speed = opts.speed;
this.resumeDelay = opts.resumeDelay || 3000;
this.rampDurationMs = (opts.rampDuration || 0) * 1000;
this.pickNewTarget();
}
pickNewTarget() {
const r = Math.random;
this.target.set((r() * 2 - 1) * (1 - this.margin), (r() * 2 - 1) * (1 - this.margin));
}
forceStop() {
this.active = false;
this.mouse.isAutoActive = false;
}
update() {
if (!this.enabled) return;
const now = performance.now();
const idle = now - this.manager.lastUserInteraction;
if (idle < this.resumeDelay) {
if (this.active) this.forceStop();
return;
}
if (this.mouse.isHoverInside) {
if (this.active) this.forceStop();
return;
}
if (!this.active) {
this.active = true;
this.current.copy(this.mouse.coords);
this.lastTime = now;
this.activationTime = now;
}
if (!this.active) return;
this.mouse.isAutoActive = true;
let dtSec = (now - this.lastTime) / 1000;
this.lastTime = now;
if (dtSec > 0.2) dtSec = 0.016;
const dir = this._tmpDir.subVectors(this.target, this.current);
const dist = dir.length();
if (dist < 0.01) {
this.pickNewTarget();
return;
}
dir.normalize();
let ramp = 1;
if (this.rampDurationMs > 0) {
const t = Math.min(1, (now - this.activationTime) / this.rampDurationMs);
ramp = t * t * (3 - 2 * t);
}
const step = this.speed * dtSec * ramp;
const move = Math.min(step, dist);
this.current.addScaledVector(dir, move);
this.mouse.setNormalized(this.current.x, this.current.y);
}
}
const face_vert = `
attribute vec3 position;
uniform vec2 px;
uniform vec2 boundarySpace;
varying vec2 uv;
precision highp float;
void main(){
vec3 pos = position;
vec2 scale = 1.0 - boundarySpace * 2.0;
pos.xy = pos.xy * scale;
uv = vec2(0.5)+(pos.xy)*0.5;
gl_Position = vec4(pos, 1.0);
}
; const line_vert =
attribute vec3 position;
uniform vec2 px;
precision highp float;
varying vec2 uv;
void main(){
vec3 pos = position;
uv = 0.5 + pos.xy * 0.5;
vec2 n = sign(pos.xy);
pos.xy = abs(pos.xy) - px * 1.0;
pos.xy *= n;
gl_Position = vec4(pos, 1.0);
}
; const mouse_vert =
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform vec2 center;
uniform vec2 scale;
uniform vec2 px;
varying vec2 vUv;
void main(){
vec2 pos = position.xy * scale * 2.0 * px + center;
vUv = uv;
gl_Position = vec4(pos, 0.0, 1.0);
}
; const advection_frag =
precision highp float;
uniform sampler2D velocity;
uniform float dt;
uniform bool isBFECC;
uniform vec2 fboSize;
uniform vec2 px;
varying vec2 uv;
void main(){
vec2 ratio = max(fboSize.x, fboSize.y) / fboSize;
if(isBFECC == false){
vec2 vel = texture2D(velocity, uv).xy;
vec2 uv2 = uv - vel * dt * ratio;
vec2 newVel = texture2D(velocity, uv2).xy;
gl_FragColor = vec4(newVel, 0.0, 0.0);
} else {
vec2 spot_new = uv;
vec2 vel_old = texture2D(velocity, uv).xy;
vec2 spot_old = spot_new - vel_old * dt * ratio;
vec2 vel_new1 = texture2D(velocity, spot_old).xy;
vec2 spot_new2 = spot_old + vel_new1 * dt * ratio;
vec2 error = spot_new2 - spot_new;
vec2 spot_new3 = spot_new - error / 2.0;
vec2 vel_2 = texture2D(velocity, spot_new3).xy;
vec2 spot_old2 = spot_new3 - vel_2 * dt * ratio;
vec2 newVel2 = texture2D(velocity, spot_old2).xy;
gl_FragColor = vec4(newVel2, 0.0, 0.0);
}
}
; const color_frag =
precision highp float;
uniform sampler2D velocity;
uniform sampler2D palette;
uniform vec4 bgColor;
varying vec2 uv;
void main(){
vec2 vel = texture2D(velocity, uv).xy;
float lenv = clamp(length(vel), 0.0, 1.0);
vec3 c = texture2D(palette, vec2(lenv, 0.5)).rgb;
vec3 outRGB = mix(bgColor.rgb, c, lenv);
float outA = mix(bgColor.a, 1.0, lenv);
gl_FragColor = vec4(outRGB, outA);
}
; const divergence_frag =
precision highp float;
uniform sampler2D velocity;
uniform float dt;
uniform vec2 px;
varying vec2 uv;
void main(){
float x0 = texture2D(velocity, uv-vec2(px.x, 0.0)).x;
float x1 = texture2D(velocity, uv+vec2(px.x, 0.0)).x;
float y0 = texture2D(velocity, uv-vec2(0.0, px.y)).y;
float y1 = texture2D(velocity, uv+vec2(0.0, px.y)).y;
float divergence = (x1 - x0 + y1 - y0) / 2.0;
gl_FragColor = vec4(divergence / dt);
}
; const externalForce_frag =
precision highp float;
uniform vec2 force;
uniform vec2 center;
uniform vec2 scale;
uniform vec2 px;
varying vec2 vUv;
void main(){
vec2 circle = (vUv - 0.5) * 2.0;
float d = 1.0 - min(length(circle), 1.0);
d *= d;
gl_FragColor = vec4(force * d, 0.0, 1.0);
}
; const poisson_frag =
precision highp float;
uniform sampler2D pressure;
uniform sampler2D divergence;
uniform vec2 px;
varying vec2 uv;
void main(){
float p0 = texture2D(pressure, uv + vec2(px.x * 2.0, 0.0)).r;
float p1 = texture2D(pressure, uv - vec2(px.x * 2.0, 0.0)).r;
float p2 = texture2D(pressure, uv + vec2(0.0, px.y * 2.0)).r;
float p3 = texture2D(pressure, uv - vec2(0.0, px.y * 2.0)).r;
float div = texture2D(divergence, uv).r;
float newP = (p0 + p1 + p2 + p3) / 4.0 - div;
gl_FragColor = vec4(newP);
}
; const pressure_frag =
precision highp float;
uniform sampler2D pressure;
uniform sampler2D velocity;
uniform vec2 px;
uniform float dt;
varying vec2 uv;
void main(){
float step = 1.0;
float p0 = texture2D(pressure, uv + vec2(px.x * step, 0.0)).r;
float p1 = texture2D(pressure, uv - vec2(px.x * step, 0.0)).r;
float p2 = texture2D(pressure, uv + vec2(0.0, px.y * step)).r;
float p3 = texture2D(pressure, uv - vec2(0.0, px.y * step)).r;
vec2 v = texture2D(velocity, uv).xy;
vec2 gradP = vec2(p0 - p1, p2 - p3) * 0.5;
v = v - gradP * dt;
gl_FragColor = vec4(v, 0.0, 1.0);
}
; const viscous_frag =
precision highp float;
uniform sampler2D velocity;
uniform sampler2D velocity_new;
uniform float v;
uniform vec2 px;
uniform float dt;
varying vec2 uv;
void main(){
vec2 old = texture2D(velocity, uv).xy;
vec2 new0 = texture2D(velocity_new, uv + vec2(px.x * 2.0, 0.0)).xy;
vec2 new1 = texture2D(velocity_new, uv - vec2(px.x * 2.0, 0.0)).xy;
vec2 new2 = texture2D(velocity_new, uv + vec2(0.0, px.y * 2.0)).xy;
vec2 new3 = texture2D(velocity_new, uv - vec2(0.0, px.y * 2.0)).xy;
vec2 newv = 4.0 * old + v * dt * (new0 + new1 + new2 + new3);
newv /= 4.0 * (1.0 + v * dt);
gl_FragColor = vec4(newv, 0.0, 0.0);
}
`;
type Uniforms = Record<string, { value: any }>;
class ShaderPass {
props: any;
uniforms?: Uniforms;
scene: THREE.Scene | null = null;
camera: THREE.Camera | null = null;
material: THREE.RawShaderMaterial | null = null;
geometry: THREE.BufferGeometry | null = null;
plane: THREE.Mesh | null = null;
constructor(props: any) {
this.props = props || {};
this.uniforms = this.props.material?.uniforms;
}
init(..._args: any[]) {
this.scene = new THREE.Scene();
this.camera = new THREE.Camera();
if (this.uniforms) {
this.material = new THREE.RawShaderMaterial(this.props.material);
this.geometry = new THREE.PlaneGeometry(2, 2);
this.plane = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.plane);
}
}
update(..._args: any[]) {
if (!Common.renderer || !this.scene || !this.camera) return;
Common.renderer.setRenderTarget(this.props.output || null);
Common.renderer.render(this.scene, this.camera);
Common.renderer.setRenderTarget(null);
}
}
class Advection extends ShaderPass {
line!: THREE.LineSegments;
constructor(simProps: any) {
super({
material: {
vertexShader: face_vert,
fragmentShader: advection_frag,
uniforms: {
boundarySpace: { value: simProps.cellScale },
px: { value: simProps.cellScale },
fboSize: { value: simProps.fboSize },
velocity: { value: simProps.src.texture },
dt: { value: simProps.dt },
isBFECC: { value: true }
}
},
output: simProps.dst
});
this.uniforms = this.props.material.uniforms;
this.init();
}
init() {
super.init();
this.createBoundary();
}
createBoundary() {
const boundaryG = new THREE.BufferGeometry();
const vertices_boundary = new Float32Array([
-1, -1, 0, -1, 1, 0, -1, 1, 0, 1, 1, 0, 1, 1, 0, 1, -1, 0, 1, -1, 0, -1, -1, 0
]);
boundaryG.setAttribute('position', new THREE.BufferAttribute(vertices_boundary, 3));
const boundaryM = new THREE.RawShaderMaterial({
vertexShader: line_vert,
fragmentShader: advection_frag,
uniforms: this.uniforms!
});
this.line = new THREE.LineSegments(boundaryG, boundaryM);
this.scene!.add(this.line);
}
update(...args: any[]) {
const { dt, isBounce, BFECC } = (args[0] || {}) as { dt?: number; isBounce?: boolean; BFECC?: boolean };
if (!this.uniforms) return;
if (typeof dt === 'number') this.uniforms.dt.value = dt;
if (typeof isBounce === 'boolean') this.line.visible = isBounce;
if (typeof BFECC === 'boolean') this.uniforms.isBFECC.value = BFECC;
super.update();
}
}
class ExternalForce extends ShaderPass {
mouse!: THREE.Mesh;
constructor(simProps: any) {
super({ output: simProps.dst });
this.init(simProps);
}
init(simProps: any) {
super.init();
const mouseG = new THREE.PlaneGeometry(1, 1);
const mouseM = new THREE.RawShaderMaterial({
vertexShader: mouse_vert,
fragmentShader: externalForce_frag,
blending: THREE.AdditiveBlending,
depthWrite: false,
uniforms: {
px: { value: simProps.cellScale },
force: { value: new THREE.Vector2(0, 0) },
center: { value: new THREE.Vector2(0, 0) },
scale: { value: new THREE.Vector2(simProps.cursor_size, simProps.cursor_size) }
}
});
this.mouse = new THREE.Mesh(mouseG, mouseM);
this.scene!.add(this.mouse);
}
update(...args: any[]) {
const props = args[0] || {};
const forceX = (Mouse.diff.x / 2) * (props.mouse_force || 0);
const forceY = (Mouse.diff.y / 2) * (props.mouse_force || 0);
const cellScale = props.cellScale || { x: 1, y: 1 };
const cursorSize = props.cursor_size || 0;
const cursorSizeX = cursorSize * cellScale.x;
const cursorSizeY = cursorSize * cellScale.y;
const centerX = Math.min(
Math.max(Mouse.coords.x, -1 + cursorSizeX + cellScale.x * 2),
1 - cursorSizeX - cellScale.x * 2
);
const centerY = Math.min(
Math.max(Mouse.coords.y, -1 + cursorSizeY + cellScale.y * 2),
1 - cursorSizeY - cellScale.y * 2
);
const uniforms = (this.mouse.material as THREE.RawShaderMaterial).uniforms;
uniforms.force.value.set(forceX, forceY);
uniforms.center.value.set(centerX, centerY);
uniforms.scale.value.set(cursorSize, cursorSize);
super.update();
}
}
class Viscous extends ShaderPass {
constructor(simProps: any) {
super({
material: {
vertexShader: face_vert,
fragmentShader: viscous_frag,
uniforms: {
boundarySpace: { value: simProps.boundarySpace },
velocity: { value: simProps.src.texture },
velocity_new: { value: simProps.dst_.texture },
v: { value: simProps.viscous },
px: { value: simProps.cellScale },
dt: { value: simProps.dt }
}
},
output: simProps.dst,
output0: simProps.dst_,
output1: simProps.dst
});
this.init();
}
update(...args: any[]) {
const { viscous, iterations, dt } = (args[0] || {}) as { viscous?: number; iterations?: number; dt?: number };
if (!this.uniforms) return;
let fbo_in: any, fbo_out: any;
if (typeof viscous === 'number') this.uniforms.v.value = viscous;
const iter = iterations ?? 0;
for (let i = 0; i < iter; i++) {
if (i % 2 === 0) {
fbo_in = this.props.output0;
fbo_out = this.props.output1;
} else {
fbo_in = this.props.output1;
fbo_out = this.props.output0;
}
this.uniforms.velocity_new.value = fbo_in.texture;
this.props.output = fbo_out;
if (typeof dt === 'number') this.uniforms.dt.value = dt;
super.update();
}
return fbo_out;
}
}
class Divergence extends ShaderPass {
constructor(simProps: any) {
super({
material: {
vertexShader: face_vert,
fragmentShader: divergence_frag,
uniforms: {
boundarySpace: { value: simProps.boundarySpace },
velocity: { value: simProps.src.texture },
px: { value: simProps.cellScale },
dt: { value: simProps.dt }
}
},
output: simProps.dst
});
this.init();
}
update(...args: any[]) {
const { vel } = (args[0] || {}) as { vel?: any };
if (this.uniforms && vel) {
this.uniforms.velocity.value = vel.texture;
}
super.update();
}
}
class Poisson extends ShaderPass {
constructor(simProps: any) {
super({
material: {
vertexShader: face_vert,
fragmentShader: poisson_frag,
uniforms: {
boundarySpace: { value: simProps.boundarySpace },
pressure: { value: simProps.dst_.texture },
divergence: { value: simProps.src.texture },
px: { value: simProps.cellScale }
}
},
output: simProps.dst,
output0: simProps.dst_,
output1: simProps.dst
});
this.init();
}
update(...args: any[]) {
const { iterations } = (args[0] || {}) as { iterations?: number };
let p_in: any, p_out: any;
const iter = iterations ?? 0;
for (let i = 0; i < iter; i++) {
if (i % 2 === 0) {
p_in = this.props.output0;
p_out = this.props.output1;
} else {
p_in = this.props.output1;
p_out = this.props.output0;
}
if (this.uniforms) this.uniforms.pressure.value = p_in.texture;
this.props.output = p_out;
super.update();
}
return p_out;
}
}
class Pressure extends ShaderPass {
constructor(simProps: any) {
super({
material: {
vertexShader: face_vert,
fragmentShader: pressure_frag,
uniforms: {
boundarySpace: { value: simProps.boundarySpace },
pressure: { value: simProps.src_p.texture },
velocity: { value: simProps.src_v.texture },
px: { value: simProps.cellScale },
dt: { value: simProps.dt }
}
},
output: simProps.dst
});
this.init();
}
update(...args: any[]) {
const { vel, pressure } = (args[0] || {}) as { vel?: any; pressure?: any };
if (this.uniforms && vel && pressure) {
this.uniforms.velocity.value = vel.texture;
this.uniforms.pressure.value = pressure.texture;
}
super.update();
}
}
class Simulation {
options: SimOptions;
fbos: Record<string, THREE.WebGLRenderTarget | null> = {
vel_0: null,
vel_1: null,
vel_viscous0: null,
vel_viscous1: null,
div: null,
pressure_0: null,
pressure_1: null
};
fboSize = new THREE.Vector2();
cellScale = new THREE.Vector2();
boundarySpace = new THREE.Vector2();
advection!: Advection;
externalForce!: ExternalForce;
viscous!: Viscous;
divergence!: Divergence;
poisson!: Poisson;
pressure!: Pressure;
constructor(options?: Partial<SimOptions>) {
this.options = {
iterations_poisson: 32,
iterations_viscous: 32,
mouse_force: 20,
resolution: 0.5,
cursor_size: 100,
viscous: 30,
isBounce: false,
dt: 0.014,
isViscous: false,
BFECC: true,
...options
};
this.init();
}
init() {
this.calcSize();
this.createAllFBO();
this.createShaderPass();
}
getFloatType() {
const isIOS = /(iPad|iPhone|iPod)/i.test(navigator.userAgent);
return isIOS ? THREE.HalfFloatType : THREE.FloatType;
}
createAllFBO() {
const type = this.getFloatType();
const opts = {
type,
depthBuffer: false,
stencilBuffer: false,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
wrapS: THREE.ClampToEdgeWrapping,
wrapT: THREE.ClampToEdgeWrapping
} as const;
for (const key in this.fbos) {
this.fbos[key] = new THREE.WebGLRenderTarget(this.fboSize.x, this.fboSize.y, opts);
}
}
createShaderPass() {
this.advection = new Advection({
cellScale: this.cellScale,
fboSize: this.fboSize,
dt: this.options.dt,
src: this.fbos.vel_0,
dst: this.fbos.vel_1
});
this.externalForce = new ExternalForce({
cellScale: this.cellScale,
cursor_size: this.options.cursor_size,
dst: this.fbos.vel_1
});
this.viscous = new Viscous({
cellScale: this.cellScale,
boundarySpace: this.boundarySpace,
viscous: this.options.viscous,
src: this.fbos.vel_1,
dst: this.fbos.vel_viscous1,
dst_: this.fbos.vel_viscous0,
dt: this.options.dt
});
this.divergence = new Divergence({
cellScale: this.cellScale,
boundarySpace: this.boundarySpace,
src: this.fbos.vel_viscous0,
dst: this.fbos.div,
dt: this.options.dt
});
this.poisson = new Poisson({
cellScale: this.cellScale,
boundarySpace: this.boundarySpace,
src: this.fbos.div,
dst: this.fbos.pressure_1,
dst_: this.fbos.pressure_0
});
this.pressure = new Pressure({
cellScale: this.cellScale,
boundarySpace: this.boundarySpace,
src_p: this.fbos.pressure_0,
src_v: this.fbos.vel_viscous0,
dst: this.fbos.vel_0,
dt: this.options.dt
});
}
calcSize() {
const width = Math.max(1, Math.round(this.options.resolution * Common.width));
const height = Math.max(1, Math.round(this.options.resolution * Common.height));
this.cellScale.set(1 / width, 1 / height);
this.fboSize.set(width, height);
}
resize() {
this.calcSize();
for (const key in this.fbos) {
this.fbos[key]!.setSize(this.fboSize.x, this.fboSize.y);
}
}
update() {
if (this.options.isBounce) this.boundarySpace.set(0, 0);
else this.boundarySpace.copy(this.cellScale);
this.advection.update({ dt: this.options.dt, isBounce: this.options.isBounce, BFECC: this.options.BFECC });
this.externalForce.update({
cursor_size: this.options.cursor_size,
mouse_force: this.options.mouse_force,
cellScale: this.cellScale
});
let vel: any = this.fbos.vel_1;
if (this.options.isViscous) {
vel = this.viscous.update({
viscous: this.options.viscous,
iterations: this.options.iterations_viscous,
dt: this.options.dt
});
}
this.divergence.update({ vel });
const pressure = this.poisson.update({ iterations: this.options.iterations_poisson });
this.pressure.update({ vel, pressure });
}
}
class Output {
simulation: Simulation;
scene: THREE.Scene;
camera: THREE.Camera;
output: THREE.Mesh;
constructor() {
this.simulation = new Simulation();
this.scene = new THREE.Scene();
this.camera = new THREE.Camera();
this.output = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
new THREE.RawShaderMaterial({
vertexShader: face_vert,
fragmentShader: color_frag,
transparent: true,
depthWrite: false,
uniforms: {
velocity: { value: this.simulation.fbos.vel_0!.texture },
boundarySpace: { value: new THREE.Vector2() },
palette: { value: paletteTex },
bgColor: { value: bgVec4 }
}
})
);
this.scene.add(this.output);
}
resize() {
this.simulation.resize();
}
render() {
if (!Common.renderer) return;
Common.renderer.setRenderTarget(null);
Common.renderer.render(this.scene, this.camera);
}
update() {
this.simulation.update();
this.render();
}
}
class WebGLManager implements LiquidEtherWebGL {
props: any;
output!: Output;
autoDriver?: AutoDriver;
lastUserInteraction = performance.now();
running = false;
private _loop = this.loop.bind(this);
private _resize = this.resize.bind(this);
private _onVisibility?: () => void;
constructor(props: any) {
this.props = props;
Common.init(props.$wrapper);
Mouse.init(props.$wrapper);
Mouse.autoIntensity = props.autoIntensity;
Mouse.takeoverDuration = props.takeoverDuration;
Mouse.onInteract = () => {
this.lastUserInteraction = performance.now();
if (this.autoDriver) this.autoDriver.forceStop();
};
this.autoDriver = new AutoDriver(Mouse, this as any, {
enabled: props.autoDemo,
speed: props.autoSpeed,
resumeDelay: props.autoResumeDelay,
rampDuration: props.autoRampDuration
});
this.init();
window.addEventListener('resize', this._resize);
this._onVisibility = () => {
const hidden = document.hidden;
if (hidden) {
this.pause();
} else if (isVisibleRef.current) {
this.start();
}
};
document.addEventListener('visibilitychange', this._onVisibility);
}
init() {
if (!Common.renderer) return;
this.props.$wrapper.prepend(Common.renderer.domElement);
this.output = new Output();
}
resize() {
Common.resize();
this.output.resize();
}
render() {
if (this.autoDriver) this.autoDriver.update();
Mouse.update();
Common.update();
this.output.update();
}
loop() {
if (!this.running) return;
this.render();
rafRef.current = requestAnimationFrame(this._loop);
}
start() {
if (this.running) return;
this.running = true;
this._loop();
}
pause() {
this.running = false;
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}
dispose() {
try {
window.removeEventListener('resize', this._resize);
if (this._onVisibility) document.removeEventListener('visibilitychange', this._onVisibility);
Mouse.dispose();
if (Common.renderer) {
const canvas = Common.renderer.domElement;
if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
Common.renderer.dispose();
}
} catch {
/* noop */
}
}
}
const container = mountRef.current;
container.style.position = container.style.position || 'relative';
container.style.overflow = container.style.overflow || 'hidden';
const webgl = new WebGLManager({
$wrapper: container,
autoDemo,
autoSpeed,
autoIntensity,
takeoverDuration,
autoResumeDelay,
autoRampDuration
});
webglRef.current = webgl;
const applyOptionsFromProps = () => {
if (!webglRef.current) return;
const sim = webglRef.current.output?.simulation;
if (!sim) return;
const prevRes = sim.options.resolution;
Object.assign(sim.options, {
mouse_force: mouseForce,
cursor_size: cursorSize,
isViscous,
viscous,
iterations_viscous: iterationsViscous,
iterations_poisson: iterationsPoisson,
dt,
BFECC,
resolution,
isBounce
});
if (resolution !== prevRes) sim.resize();
};
applyOptionsFromProps();
webgl.start();
const io = new IntersectionObserver(
entries => {
const entry = entries[0];
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0;
isVisibleRef.current = isVisible;
if (!webglRef.current) return;
if (isVisible && !document.hidden) {
webglRef.current.start();
} else {
webglRef.current.pause();
}
},
{ threshold: [0, 0.01, 0.1] }
);
io.observe(container);
intersectionObserverRef.current = io;
const ro = new ResizeObserver(() => {
if (!webglRef.current) return;
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
resizeRafRef.current = requestAnimationFrame(() => {
if (!webglRef.current) return;
webglRef.current.resize();
});
});
ro.observe(container);
resizeObserverRef.current = ro;
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (resizeObserverRef.current) {
try {
resizeObserverRef.current.disconnect();
} catch {
/* noop */
}
}
if (intersectionObserverRef.current) {
try {
intersectionObserverRef.current.disconnect();
} catch {
/* noop */
}
}
if (webglRef.current) {
webglRef.current.dispose();
}
webglRef.current = null;
};
}, [ BFECC, cursorSize, dt, isBounce, isViscous, iterationsPoisson, iterationsViscous, mouseForce, resolution, viscous, colors, autoDemo, autoSpeed, autoIntensity, takeoverDuration, autoResumeDelay, autoRampDuration ]);
useEffect(() => { const webgl = webglRef.current; if (!webgl) return; const sim = webgl.output?.simulation; if (!sim) return; const prevRes = sim.options.resolution; Object.assign(sim.options, { mouse_force: mouseForce, cursor_size: cursorSize, isViscous, viscous, iterations_viscous: iterationsViscous, iterations_poisson: iterationsPoisson, dt, BFECC, resolution, isBounce }); if (webgl.autoDriver) { webgl.autoDriver.enabled = autoDemo; webgl.autoDriver.speed = autoSpeed; webgl.autoDriver.resumeDelay = autoResumeDelay; webgl.autoDriver.rampDurationMs = autoRampDuration * 1000; if (webgl.autoDriver.mouse) { webgl.autoDriver.mouse.autoIntensity = autoIntensity; webgl.autoDriver.mouse.takeoverDuration = takeoverDuration; } } if (resolution !== prevRes) sim.resize(); }, [ mouseForce, cursorSize, isViscous, viscous, iterationsViscous, iterationsPoisson, dt, BFECC, resolution, isBounce, autoDemo, autoSpeed, autoIntensity, takeoverDuration, autoResumeDelay, autoRampDuration ]);
return (
<div
ref={mountRef}
className={w-full h-full relative overflow-hidden pointer-events-none touch-none ${className || ''}}
style={style}
/>
);
}
import BubbleMenu from './BubbleMenu'
const items = [ { label: 'home', href: '#', ariaLabel: 'Home', rotation: -8, hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' } }, { label: 'about', href: '#', ariaLabel: 'About', rotation: 8, hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' } }, { label: 'projects', href: '#', ariaLabel: 'Projects', rotation: 8, hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' } }, { label: 'blog', href: '#', ariaLabel: 'Blog', rotation: 8, hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' } }, { label: 'contact', href: '#', ariaLabel: 'Contact', rotation: -8, hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' } } ];
<BubbleMenu logo={<span style={{ fontWeight: 700 }}>RB} items={items} menuAriaLabel="Toggle navigation" menuBg="#ffffff" menuContentColor="#111111" useFixedPosition={false} animationEase="back.out(1.5)" animationDuration={0.5} staggerDelay={0.12} />
import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; import { gsap } from 'gsap';
type MenuItem = { label: string; href: string; ariaLabel?: string; rotation?: number; hoverStyles?: { bgColor?: string; textColor?: string; }; };
export type BubbleMenuProps = { logo: ReactNode | string; onMenuClick?: (open: boolean) => void; className?: string; style?: CSSProperties; menuAriaLabel?: string; menuBg?: string; menuContentColor?: string; useFixedPosition?: boolean; items?: MenuItem[]; animationEase?: string; animationDuration?: number; staggerDelay?: number; };
const DEFAULT_ITEMS: MenuItem[] = [ { label: 'home', href: '#', ariaLabel: 'Home', rotation: -8, hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' } }, { label: 'about', href: '#', ariaLabel: 'About', rotation: 8, hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' } }, { label: 'projects', href: '#', ariaLabel: 'Documentation', rotation: 8, hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' } }, { label: 'blog', href: '#', ariaLabel: 'Blog', rotation: 8, hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' } }, { label: 'contact', href: '#', ariaLabel: 'Contact', rotation: -8, hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' } } ];
export default function BubbleMenu({ logo, onMenuClick, className, style, menuAriaLabel = 'Toggle menu', menuBg = '#fff', menuContentColor = '#111', useFixedPosition = false, items, animationEase = 'back.out(1.5)', animationDuration = 0.5, staggerDelay = 0.12 }: BubbleMenuProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); const [showOverlay, setShowOverlay] = useState(false);
const overlayRef = useRef(null); const bubblesRef = useRef<HTMLAnchorElement[]>([]); const labelRefs = useRef<HTMLSpanElement[]>([]);
const menuItems = items?.length ? items : DEFAULT_ITEMS;
const containerClassName = [ 'bubble-menu', useFixedPosition ? 'fixed' : 'absolute', 'left-0 right-0 top-8', 'flex items-center justify-between', 'gap-4 px-8', 'pointer-events-none', 'z-[1001]', className ] .filter(Boolean) .join(' ');
const handleToggle = () => { const nextState = !isMenuOpen; if (nextState) setShowOverlay(true); setIsMenuOpen(nextState); onMenuClick?.(nextState); };
useEffect(() => { const overlay = overlayRef.current; const bubbles = bubblesRef.current.filter(Boolean); const labels = labelRefs.current.filter(Boolean); if (!overlay || !bubbles.length) return;
if (isMenuOpen) {
gsap.set(overlay, { display: 'flex' });
gsap.killTweensOf([...bubbles, ...labels]);
gsap.set(bubbles, { scale: 0, transformOrigin: '50% 50%' });
gsap.set(labels, { y: 24, autoAlpha: 0 });
bubbles.forEach((bubble, i) => {
const delay = i * staggerDelay + gsap.utils.random(-0.05, 0.05);
const tl = gsap.timeline({ delay });
tl.to(bubble, {
scale: 1,
duration: animationDuration,
ease: animationEase
});
if (labels[i]) {
tl.to(
labels[i],
{
y: 0,
autoAlpha: 1,
duration: animationDuration,
ease: 'power3.out'
},
'-=' + animationDuration * 0.9
);
}
});
} else if (showOverlay) {
gsap.killTweensOf([...bubbles, ...labels]);
gsap.to(labels, {
y: 24,
autoAlpha: 0,
duration: 0.2,
ease: 'power3.in'
});
gsap.to(bubbles, {
scale: 0,
duration: 0.2,
ease: 'power3.in',
onComplete: () => {
gsap.set(overlay, { display: 'none' });
setShowOverlay(false);
}
});
}
}, [isMenuOpen, showOverlay, animationEase, animationDuration, staggerDelay]);
useEffect(() => { const handleResize = () => { if (isMenuOpen) { const bubbles = bubblesRef.current.filter(Boolean); const isDesktop = window.innerWidth >= 900; bubbles.forEach((bubble, i) => { const item = menuItems[i]; if (bubble && item) { const rotation = isDesktop ? (item.rotation ?? 0) : 0; gsap.set(bubble, { rotation }); } }); } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [isMenuOpen, menuItems]);
return (
<>
{/* Workaround for silly Tailwind capabilities */}
<style>{.bubble-menu .menu-line { transition: transform 0.3s ease, opacity 0.3s ease; transform-origin: center; } .bubble-menu-items .pill-list .pill-col:nth-child(4):nth-last-child(2) { margin-left: calc(100% / 6); } .bubble-menu-items .pill-list .pill-col:nth-child(4):last-child { margin-left: calc(100% / 3); } @media (min-width: 900px) { .bubble-menu-items .pill-link { transform: rotate(var(--item-rot)); } .bubble-menu-items .pill-link:hover { transform: rotate(var(--item-rot)) scale(1.06); background: var(--hover-bg) !important; color: var(--hover-color) !important; } .bubble-menu-items .pill-link:active { transform: rotate(var(--item-rot)) scale(.94); } } @media (max-width: 899px) { .bubble-menu-items { padding-top: 120px; align-items: flex-start; } .bubble-menu-items .pill-list { row-gap: 16px; } .bubble-menu-items .pill-list .pill-col { flex: 0 0 100% !important; margin-left: 0 !important; overflow: visible; } .bubble-menu-items .pill-link { font-size: clamp(1.2rem, 3vw, 4rem); padding: clamp(1rem, 2vw, 2rem) 0; min-height: 80px !important; } .bubble-menu-items .pill-link:hover { transform: scale(1.06); background: var(--hover-bg); color: var(--hover-color); } .bubble-menu-items .pill-link:active { transform: scale(.94); } }}</style>
<nav className={containerClassName} style={style} aria-label="Main navigation">
<div
className={[
'bubble logo-bubble',
'inline-flex items-center justify-center',
'rounded-full',
'bg-white',
'shadow-[0_4px_16px_rgba(0,0,0,0.12)]',
'pointer-events-auto',
'h-12 md:h-14',
'px-4 md:px-8',
'gap-2',
'will-change-transform'
].join(' ')}
aria-label="Logo"
style={{
background: menuBg,
minHeight: '48px',
borderRadius: '9999px'
}}
>
<span
className={['logo-content', 'inline-flex items-center justify-center', 'w-[120px] h-full'].join(' ')}
style={
{
['--logo-max-height']: '60%',
['--logo-max-width']: '100%'
} as CSSProperties
}
>
{typeof logo === 'string' ? (
<img src={logo} alt="Logo" className="bubble-logo max-h-[60%] max-w-full object-contain block" />
) : (
logo
)}
</span>
</div>
<button
type="button"
className={[
'bubble toggle-bubble menu-btn',
isMenuOpen ? 'open' : '',
'inline-flex flex-col items-center justify-center',
'rounded-full',
'bg-white',
'shadow-[0_4px_16px_rgba(0,0,0,0.12)]',
'pointer-events-auto',
'w-12 h-12 md:w-14 md:h-14',
'border-0 cursor-pointer p-0',
'will-change-transform'
].join(' ')}
onClick={handleToggle}
aria-label={menuAriaLabel}
aria-pressed={isMenuOpen}
style={{ background: menuBg }}
>
<span
className="menu-line block mx-auto rounded-[2px]"
style={{
width: 26,
height: 2,
background: menuContentColor,
transform: isMenuOpen ? 'translateY(4px) rotate(45deg)' : 'none'
}}
/>
<span
className="menu-line short block mx-auto rounded-[2px]"
style={{
marginTop: '6px',
width: 26,
height: 2,
background: menuContentColor,
transform: isMenuOpen ? 'translateY(-4px) rotate(-45deg)' : 'none'
}}
/>
</button>
</nav>
{showOverlay && (
<div
ref={overlayRef}
className={[
'bubble-menu-items',
useFixedPosition ? 'fixed' : 'absolute',
'inset-0',
'flex items-center justify-center',
'pointer-events-none',
'z-[1000]'
].join(' ')}
aria-hidden={!isMenuOpen}
>
<ul
className={[
'pill-list',
'list-none m-0 px-6',
'w-full max-w-[1600px] mx-auto',
'flex flex-wrap',
'gap-x-0 gap-y-1',
'pointer-events-auto'
].join(' ')}
role="menu"
aria-label="Menu links"
>
{menuItems.map((item, idx) => (
<li
key={idx}
role="none"
className={[
'pill-col',
'flex justify-center items-stretch',
'[flex:0_0_calc(100%/3)]',
'box-border'
].join(' ')}
>
<a
role="menuitem"
href={item.href}
aria-label={item.ariaLabel || item.label}
className={[
'pill-link',
'w-full',
'rounded-[999px]',
'no-underline',
'bg-white',
'text-inherit',
'shadow-[0_4px_14px_rgba(0,0,0,0.10)]',
'flex items-center justify-center',
'relative',
'transition-[background,color] duration-300 ease-in-out',
'box-border',
'whitespace-nowrap overflow-hidden'
].join(' ')}
style={
{
['--item-rot']: `${item.rotation ?? 0}deg`,
['--pill-bg']: menuBg,
['--pill-color']: menuContentColor,
['--hover-bg']: item.hoverStyles?.bgColor || '#f3f4f6',
['--hover-color']: item.hoverStyles?.textColor || menuContentColor,
background: 'var(--pill-bg)',
color: 'var(--pill-color)',
minHeight: 'var(--pill-min-h, 160px)',
padding: 'clamp(1.5rem, 3vw, 8rem) 0',
fontSize: 'clamp(1.5rem, 4vw, 4rem)',
fontWeight: 400,
lineHeight: 0,
willChange: 'transform',
height: 10
} as CSSProperties
}
ref={el => {
if (el) bubblesRef.current[idx] = el;
}}
>
<span
className="pill-label inline-block"
style={{
willChange: 'transform, opacity',
height: '1.2em',
lineHeight: 1.2
}}
ref={el => {
if (el) labelRefs.current[idx] = el;
}}
>
{item.label}
</span>
</a>
</li>
))}
</ul>
</div>
)}
</>
); }
Circular Gallery Install
CLI Manual pnpm npm yarn bun npm install ogl usage import CircularGallery from './CircularGallery'
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'; import { useEffect, useRef } from 'react';
type GL = Renderer['gl'];
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) { let timeout: number; return function (this: any, ...args: Parameters) { window.clearTimeout(timeout); timeout = window.setTimeout(() => func.apply(this, args), wait); }; }
function lerp(p1: number, p2: number, t: number): number { return p1 + (p2 - p1) * t; }
function autoBind(instance: any): void { const proto = Object.getPrototypeOf(instance); Object.getOwnPropertyNames(proto).forEach(key => { if (key !== 'constructor' && typeof instance[key] === 'function') { instance[key] = instance[key].bind(instance); } }); }
function getFontSize(font: string): number { const match = font.match(/(\d+)px/); return match ? parseInt(match[1], 10) : 30; }
function createTextTexture( gl: GL, text: string, font: string = 'bold 30px monospace', color: string = 'black' ): { texture: Texture; width: number; height: number } { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) throw new Error('Could not get 2d context');
context.font = font; const metrics = context.measureText(text); const textWidth = Math.ceil(metrics.width); const fontSize = getFontSize(font); const textHeight = Math.ceil(fontSize * 1.2);
canvas.width = textWidth + 20; canvas.height = textHeight + 20;
context.font = font; context.fillStyle = color; context.textBaseline = 'middle'; context.textAlign = 'center'; context.clearRect(0, 0, canvas.width, canvas.height); context.fillText(text, canvas.width / 2, canvas.height / 2);
const texture = new Texture(gl, { generateMipmaps: false }); texture.image = canvas; return { texture, width: canvas.width, height: canvas.height }; }
interface TitleProps { gl: GL; plane: Mesh; renderer: Renderer; text: string; textColor?: string; font?: string; }
class Title { gl: GL; plane: Mesh; renderer: Renderer; text: string; textColor: string; font: string; mesh!: Mesh;
constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) { autoBind(this); this.gl = gl; this.plane = plane; this.renderer = renderer; this.text = text; this.textColor = textColor; this.font = font; this.createMesh(); }
createMesh() {
const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor);
const geometry = new Plane(this.gl);
const program = new Program(this.gl, {
vertex: attribute vec3 position; attribute vec2 uv; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); },
fragment: precision highp float; uniform sampler2D tMap; varying vec2 vUv; void main() { vec4 color = texture2D(tMap, vUv); if (color.a < 0.1) discard; gl_FragColor = color; },
uniforms: { tMap: { value: texture } },
transparent: true
});
this.mesh = new Mesh(this.gl, { geometry, program });
const aspect = width / height;
const textHeightScaled = this.plane.scale.y * 0.15;
const textWidthScaled = textHeightScaled * aspect;
this.mesh.scale.set(textWidthScaled, textHeightScaled, 1);
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05;
this.mesh.setParent(this.plane);
}
}
interface ScreenSize { width: number; height: number; }
interface Viewport { width: number; height: number; }
interface MediaProps { geometry: Plane; gl: GL; image: string; index: number; length: number; renderer: Renderer; scene: Transform; screen: ScreenSize; text: string; viewport: Viewport; bend: number; textColor: string; borderRadius?: number; font?: string; }
class Media { extra: number = 0; geometry: Plane; gl: GL; image: string; index: number; length: number; renderer: Renderer; scene: Transform; screen: ScreenSize; text: string; viewport: Viewport; bend: number; textColor: string; borderRadius: number; font?: string; program!: Program; plane!: Mesh; title!: Title; scale!: number; padding!: number; width!: number; widthTotal!: number; x!: number; speed: number = 0; isBefore: boolean = false; isAfter: boolean = false;
constructor({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport, bend, textColor, borderRadius = 0, font }: MediaProps) { this.geometry = geometry; this.gl = gl; this.image = image; this.index = index; this.length = length; this.renderer = renderer; this.scene = scene; this.screen = screen; this.text = text; this.viewport = viewport; this.bend = bend; this.textColor = textColor; this.borderRadius = borderRadius; this.font = font; this.createShader(); this.createMesh(); this.createTitle(); this.onResize(); }
createShader() {
const texture = new Texture(this.gl, {
generateMipmaps: true
});
this.program = new Program(this.gl, {
depthTest: false,
depthWrite: false,
vertex: precision highp float; attribute vec3 position; attribute vec2 uv; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform float uTime; uniform float uSpeed; varying vec2 vUv; void main() { vUv = uv; vec3 p = position; p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5); gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); },
fragment: `
precision highp float;
uniform vec2 uImageSizes;
uniform vec2 uPlaneSizes;
uniform sampler2D tMap;
uniform float uBorderRadius;
varying vec2 vUv;
float roundedBoxSDF(vec2 p, vec2 b, float r) {
vec2 d = abs(p) - b;
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
}
void main() {
vec2 ratio = vec2(
min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),
min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)
);
vec2 uv = vec2(
vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
);
vec4 color = texture2D(tMap, uv);
float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius);
// Smooth antialiasing for edges
float edgeSmooth = 0.002;
float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d);
gl_FragColor = vec4(color.rgb, alpha);
}
`,
uniforms: {
tMap: { value: texture },
uPlaneSizes: { value: [0, 0] },
uImageSizes: { value: [0, 0] },
uSpeed: { value: 0 },
uTime: { value: 100 * Math.random() },
uBorderRadius: { value: this.borderRadius }
},
transparent: true
});
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = this.image;
img.onload = () => {
texture.image = img;
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight];
};
}
createMesh() { this.plane = new Mesh(this.gl, { geometry: this.geometry, program: this.program }); this.plane.setParent(this.scene); }
createTitle() { this.title = new Title({ gl: this.gl, plane: this.plane, renderer: this.renderer, text: this.text, textColor: this.textColor, font: this.font }); }
update(scroll: { current: number; last: number }, direction: 'right' | 'left') { this.plane.position.x = this.x - scroll.current - this.extra;
const x = this.plane.position.x;
const H = this.viewport.width / 2;
if (this.bend === 0) {
this.plane.position.y = 0;
this.plane.rotation.z = 0;
} else {
const B_abs = Math.abs(this.bend);
const R = (H * H + B_abs * B_abs) / (2 * B_abs);
const effectiveX = Math.min(Math.abs(x), H);
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX);
if (this.bend > 0) {
this.plane.position.y = -arc;
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R);
} else {
this.plane.position.y = arc;
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R);
}
}
this.speed = scroll.current - scroll.last;
this.program.uniforms.uTime.value += 0.04;
this.program.uniforms.uSpeed.value = this.speed;
const planeOffset = this.plane.scale.x / 2;
const viewportOffset = this.viewport.width / 2;
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset;
this.isAfter = this.plane.position.x - planeOffset > viewportOffset;
if (direction === 'right' && this.isBefore) {
this.extra -= this.widthTotal;
this.isBefore = this.isAfter = false;
}
if (direction === 'left' && this.isAfter) {
this.extra += this.widthTotal;
this.isBefore = this.isAfter = false;
}
}
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) { if (screen) this.screen = screen; if (viewport) { this.viewport = viewport; if (this.plane.program.uniforms.uViewportSizes) { this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]; } } this.scale = this.screen.height / 1500; this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height; this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width; this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]; this.padding = 2; this.width = this.plane.scale.x + this.padding; this.widthTotal = this.width * this.length; this.x = this.width * this.index; } }
interface AppConfig { items?: { image: string; text: string }[]; bend?: number; textColor?: string; borderRadius?: number; font?: string; scrollSpeed?: number; scrollEase?: number; }
class App { container: HTMLElement; scrollSpeed: number; scroll: { ease: number; current: number; target: number; last: number; position?: number; }; onCheckDebounce: (...args: any[]) => void; renderer!: Renderer; gl!: GL; camera!: Camera; scene!: Transform; planeGeometry!: Plane; medias: Media[] = []; mediasImages: { image: string; text: string }[] = []; screen!: { width: number; height: number }; viewport!: { width: number; height: number }; raf: number = 0;
boundOnResize!: () => void; boundOnWheel!: (e: Event) => void; boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void; boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void; boundOnTouchUp!: () => void;
isDown: boolean = false; start: number = 0;
constructor( container: HTMLElement, { items, bend = 1, textColor = '#ffffff', borderRadius = 0, font = 'bold 30px Figtree', scrollSpeed = 2, scrollEase = 0.05 }: AppConfig ) { document.documentElement.classList.remove('no-js'); this.container = container; this.scrollSpeed = scrollSpeed; this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }; this.onCheckDebounce = debounce(this.onCheck.bind(this), 200); this.createRenderer(); this.createCamera(); this.createScene(); this.onResize(); this.createGeometry(); this.createMedias(items, bend, textColor, borderRadius, font); this.update(); this.addEventListeners(); }
createRenderer() { this.renderer = new Renderer({ alpha: true, antialias: true, dpr: Math.min(window.devicePixelRatio || 1, 2) }); this.gl = this.renderer.gl; this.gl.clearColor(0, 0, 0, 0); this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement); }
createCamera() { this.camera = new Camera(this.gl); this.camera.fov = 45; this.camera.position.z = 20; }
createScene() { this.scene = new Transform(); }
createGeometry() { this.planeGeometry = new Plane(this.gl, { heightSegments: 50, widthSegments: 100 }); }
createMedias(
items: { image: string; text: string }[] | undefined,
bend: number = 1,
textColor: string,
borderRadius: number,
font: string
) {
const defaultItems = [
{
image: https://picsum.photos/seed/1/800/600?grayscale,
text: 'Bridge'
},
{
image: https://picsum.photos/seed/2/800/600?grayscale,
text: 'Desk Setup'
},
{
image: https://picsum.photos/seed/3/800/600?grayscale,
text: 'Waterfall'
},
{
image: https://picsum.photos/seed/4/800/600?grayscale,
text: 'Strawberries'
},
{
image: https://picsum.photos/seed/5/800/600?grayscale,
text: 'Deep Diving'
},
{
image: https://picsum.photos/seed/16/800/600?grayscale,
text: 'Train Track'
},
{
image: https://picsum.photos/seed/17/800/600?grayscale,
text: 'Santorini'
},
{
image: https://picsum.photos/seed/8/800/600?grayscale,
text: 'Blurry Lights'
},
{
image: https://picsum.photos/seed/9/800/600?grayscale,
text: 'New York'
},
{
image: https://picsum.photos/seed/10/800/600?grayscale,
text: 'Good Boy'
},
{
image: https://picsum.photos/seed/21/800/600?grayscale,
text: 'Coastline'
},
{
image: https://picsum.photos/seed/12/800/600?grayscale,
text: 'Palm Trees'
}
];
const galleryItems = items && items.length ? items : defaultItems;
this.mediasImages = galleryItems.concat(galleryItems);
this.medias = this.mediasImages.map((data, index) => {
return new Media({
geometry: this.planeGeometry,
gl: this.gl,
image: data.image,
index,
length: this.mediasImages.length,
renderer: this.renderer,
scene: this.scene,
screen: this.screen,
text: data.text,
viewport: this.viewport,
bend,
textColor,
borderRadius,
font
});
});
}
onTouchDown(e: MouseEvent | TouchEvent) { this.isDown = true; this.scroll.position = this.scroll.current; this.start = 'touches' in e ? e.touches[0].clientX : e.clientX; }
onTouchMove(e: MouseEvent | TouchEvent) { if (!this.isDown) return; const x = 'touches' in e ? e.touches[0].clientX : e.clientX; const distance = (this.start - x) * (this.scrollSpeed * 0.025); this.scroll.target = (this.scroll.position ?? 0) + distance; }
onTouchUp() { this.isDown = false; this.onCheck(); }
onWheel(e: Event) { const wheelEvent = e as WheelEvent; const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail; this.scroll.target += (delta > 0 ? this.scrollSpeed : -this.scrollSpeed) * 0.2; this.onCheckDebounce(); }
onCheck() { if (!this.medias || !this.medias[0]) return; const width = this.medias[0].width; const itemIndex = Math.round(Math.abs(this.scroll.target) / width); const item = width * itemIndex; this.scroll.target = this.scroll.target < 0 ? -item : item; }
onResize() { this.screen = { width: this.container.clientWidth, height: this.container.clientHeight }; this.renderer.setSize(this.screen.width, this.screen.height); this.camera.perspective({ aspect: this.screen.width / this.screen.height }); const fov = (this.camera.fov * Math.PI) / 180; const height = 2 * Math.tan(fov / 2) * this.camera.position.z; const width = height * this.camera.aspect; this.viewport = { width, height }; if (this.medias) { this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport })); } }
update() { this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease); const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'; if (this.medias) { this.medias.forEach(media => media.update(this.scroll, direction)); } this.renderer.render({ scene: this.scene, camera: this.camera }); this.scroll.last = this.scroll.current; this.raf = window.requestAnimationFrame(this.update.bind(this)); }
addEventListeners() { this.boundOnResize = this.onResize.bind(this); this.boundOnWheel = this.onWheel.bind(this); this.boundOnTouchDown = this.onTouchDown.bind(this); this.boundOnTouchMove = this.onTouchMove.bind(this); this.boundOnTouchUp = this.onTouchUp.bind(this); window.addEventListener('resize', this.boundOnResize); window.addEventListener('mousewheel', this.boundOnWheel); window.addEventListener('wheel', this.boundOnWheel); window.addEventListener('mousedown', this.boundOnTouchDown); window.addEventListener('mousemove', this.boundOnTouchMove); window.addEventListener('mouseup', this.boundOnTouchUp); window.addEventListener('touchstart', this.boundOnTouchDown); window.addEventListener('touchmove', this.boundOnTouchMove); window.addEventListener('touchend', this.boundOnTouchUp); }
destroy() { window.cancelAnimationFrame(this.raf); window.removeEventListener('resize', this.boundOnResize); window.removeEventListener('mousewheel', this.boundOnWheel); window.removeEventListener('wheel', this.boundOnWheel); window.removeEventListener('mousedown', this.boundOnTouchDown); window.removeEventListener('mousemove', this.boundOnTouchMove); window.removeEventListener('mouseup', this.boundOnTouchUp); window.removeEventListener('touchstart', this.boundOnTouchDown); window.removeEventListener('touchmove', this.boundOnTouchMove); window.removeEventListener('touchend', this.boundOnTouchUp); if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) { this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement); } } }
interface CircularGalleryProps { items?: { image: string; text: string }[]; bend?: number; textColor?: string; borderRadius?: number; font?: string; scrollSpeed?: number; scrollEase?: number; }
export default function CircularGallery({ items, bend = 3, textColor = '#ffffff', borderRadius = 0.05, font = 'bold 30px Figtree', scrollSpeed = 2, scrollEase = 0.05 }: CircularGalleryProps) { const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const app = new App(containerRef.current, { items, bend, textColor, borderRadius, font, scrollSpeed, scrollEase }); return () => { app.destroy(); }; }, [items, bend, textColor, borderRadius, font, scrollSpeed, scrollEase]); return