"use client";
import { memo, useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
export interface MonolithProps {
slabColor?: string;
rimColor?: string;
sunColor?: string;
sun?: boolean;
backgroundColor?: string;
spinSpeed?: number;
floatAmp?: number;
scale?: number;
zoom?: number;
autoSpin?: boolean;
parallax?: boolean;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const RADIUS = 8.4;
const PHI_MIN = 1.08;
const PHI_MAX = 1.56;
const Monolith = memo(
({
slabColor = "#241d3e",
rimColor = "#a855f7",
sunColor = "#4c1d95",
sun = false,
backgroundColor = "#05040a",
spinSpeed = 0.03,
floatAmp = 0.08,
scale = 1,
zoom = 1,
autoSpin = true,
parallax = false,
paused = false,
reducedMotion = false,
className,
}: MonolithProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
const measureRef = useRef<((m: { width: number; height: number; dpr: number }) => void) | null>(
null,
);
const glRef = useRef<WebGLRenderingContext | WebGL2RenderingContext | null>(
null,
);
const orbit = useRef({
theta: 0,
phi: PHI_MAX,
vTheta: 0,
vPhi: 0,
dragging: false,
pointerId: -1,
lastX: 0,
lastY: 0,
lastT: 0,
});
const [fallback, setFallback] = useState(false);
const pausedRef = useRef(paused);
pausedRef.current = paused;
const pointer = useRef({ x: 0, y: 0, tx: 0, ty: 0 });
const loop = useAnimationLoop({
target: containerRef,
halted: paused,
dpr: "auto",
onResize: (metrics) => measureRef.current?.(metrics),
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
gl: () => glRef.current,
});
const live = useRef({
slabColor, rimColor, sunColor, sun, backgroundColor,
spinSpeed, floatAmp, scale, zoom, autoSpin, parallax, reducedMotion,
});
live.current = {
slabColor, rimColor, sunColor, sun, backgroundColor,
spinSpeed, floatAmp, scale, zoom, autoSpin, parallax, reducedMotion,
};
useEffect(() => {
const container = containerRef.current;
if (fallback || !container) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
} catch {
setFallback(true);
return;
}
const glc = renderer.getContext();
glRef.current = glc;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
const canvas = renderer.domElement;
canvas.style.display = "block";
canvas.style.position = "absolute";
canvas.style.top = "0";
canvas.style.left = "0";
container.appendChild(canvas);
const scene = new THREE.Scene();
scene.background = new THREE.Color(backgroundColor);
const target = new THREE.Vector3(0, 0.35, 0);
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 260);
const hemi = new THREE.HemisphereLight(
new THREE.Color(sunColor),
new THREE.Color("#0a0812"),
0.7,
);
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(2.5, 3, 2);
const back = new THREE.DirectionalLight(new THREE.Color(rimColor), 1.4);
back.position.set(-2.5, 2, -4);
const glowLight = new THREE.DirectionalLight(new THREE.Color(sunColor), 0.9);
scene.add(camera);
camera.add(key, back, glowLight);
scene.add(hemi);
const monolith = new THREE.Group();
const slabMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color(slabColor),
roughness: 0.38,
metalness: 0.5,
emissive: new THREE.Color(rimColor),
emissiveIntensity: 0.05,
});
const slab = new THREE.Mesh(
new THREE.BoxGeometry(0.92, 2.7, 0.34),
slabMaterial,
);
const seamMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(rimColor),
});
const seam = new THREE.Mesh(
new THREE.BoxGeometry(0.024, 2.3, 0.02),
seamMaterial,
);
seam.position.set(0, 0, 0.175);
const seamBack = new THREE.Mesh(
new THREE.BoxGeometry(0.024, 2.3, 0.02),
seamMaterial,
);
seamBack.position.set(0, 0, -0.175);
monolith.add(slab, seam, seamBack);
scene.add(monolith);
const floorMaterial = new THREE.ShaderMaterial({
uniforms: {
uBg: { value: new THREE.Color(backgroundColor) },
uLine: { value: new THREE.Color(rimColor) },
uTime: { value: 0 },
},
vertexShader: `
varying vec3 vWorld;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorld = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: `
precision highp float;
varying vec3 vWorld;
uniform vec3 uBg;
uniform vec3 uLine;
uniform float uTime;
void main() {
// Analytic AA on the line mask — fwidth keeps far lines from
// aliasing into moire.
vec2 g = vWorld.xz * 0.42;
vec2 w = fwidth(g) * 1.4 + 1e-4;
vec2 gg = abs(fract(g) - 0.5);
float lines = 1.0 - min(min(gg.x / w.x, gg.y / w.y), 1.0);
// Distance fade BEFORE the lines go sub-pixel: grazing-angle
// cells thinner than a pixel are what shimmer as the camera
// drifts, so the grid dissolves while it can still be resolved.
// Fading CLOSE to the viewer keeps the void gap under the sun
// wide — the grid's far edge sits low in frame, the sun high.
float r = length(vWorld.xz);
lines *= 1.0 - smoothstep(8.0, 18.0, r);
// Pulse rolling out of the vanishing point, on the scene clock.
float ring = fract(uTime * 0.14);
float pulse = exp(-abs(r / 26.0 - ring) * 26.0) * 0.5;
vec3 col = uLine * (lines * (0.42 + pulse));
// Light pool smeared back from beneath the slab — sells the hover.
float pool = exp(-length(vec2(vWorld.x, max(vWorld.z, 0.0) * 0.55)) * 0.55);
col += uLine * pool * 0.22;
// The void: lines and pool fade out well before the plane's edge,
// so the floor dissolves into nothing.
float voidMask = 1.0 - smoothstep(16.0, 26.0, r);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
// The floor's BASE is the sky. Lines and pool are light ADDED over
// it, so the plane never has a colour of its own and its edge can
// never show — on any background, not only a near-black one where
// "black between the lines" happens to pass. Added after
// tonemapping: three clears scene.background colourspace-converted
// but never tonemapped, so raw uBg is the only base that lands on
// exactly the sky.
gl_FragColor.rgb = uBg + gl_FragColor.rgb * voidMask;
#include <colorspace_fragment>
}
`,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -2.6;
scene.add(floor);
const radialTexture = (stops: Array<[number, string]>) => {
const c = document.createElement("canvas");
c.width = c.height = 256;
const ctx = c.getContext("2d");
if (ctx) {
const grad = ctx.createRadialGradient(128, 128, 0, 128, 128, 128);
for (const [at, color] of stops) grad.addColorStop(at, color);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 256);
}
return new THREE.CanvasTexture(c);
};
const sunSprite = (stops: Array<[number, string]>, opacity: number) =>
new THREE.Sprite(
new THREE.SpriteMaterial({
map: radialTexture(stops),
color: new THREE.Color(sunColor),
transparent: true,
opacity,
depthTest: false,
depthWrite: false,
fog: false,
toneMapped: false,
blending: THREE.AdditiveBlending,
}),
);
const sunCore = sunSprite(
[
[0, "rgba(255,255,255,1)"],
[0.3, "rgba(255,255,255,1)"],
[0.5, "rgba(255,255,255,0.35)"],
[1, "rgba(255,255,255,0)"],
],
1,
);
const sunGlow = sunSprite(
[
[0, "rgba(255,255,255,1)"],
[0.08, "rgba(255,255,255,0.55)"],
[0.25, "rgba(255,255,255,0.18)"],
[0.6, "rgba(255,255,255,0.04)"],
[1, "rgba(255,255,255,0)"],
],
1,
);
const sunCorona = sunSprite(
[
[0, "rgba(255,255,255,0.4)"],
[0.45, "rgba(255,255,255,0.1)"],
[1, "rgba(255,255,255,0)"],
],
0.6,
);
camera.add(sunCore, sunGlow, sunCorona);
const SUN_DEPTH = 250;
const placeSun = () => {
const halfH = SUN_DEPTH * Math.tan((camera.fov * Math.PI) / 360);
const halfW = halfH * camera.aspect;
const x = halfW * 0.58;
const y = halfH * 0.52;
sunCore.position.set(x, y, -SUN_DEPTH);
sunGlow.position.set(x, y, -SUN_DEPTH - 1);
sunCorona.position.set(x, y, -SUN_DEPTH - 2);
sunCore.scale.set(halfH * 0.075, halfH * 0.075, 1);
sunGlow.scale.set(halfH * 0.9, halfH * 0.9, 1);
sunCorona.scale.set(halfH * 2.6, halfH * 1.9, 1);
glowLight.position.set(x, y, -SUN_DEPTH).normalize().multiplyScalar(5);
};
placeSun();
const starCount = 320;
const starPositions = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const a = Math.random() * Math.PI * 2;
const r = 26 + Math.random() * 22;
const y = 3 + Math.random() * 26;
starPositions[i * 3] = Math.cos(a) * r;
starPositions[i * 3 + 1] = y;
starPositions[i * 3 + 2] = Math.sin(a) * r;
}
const starGeometry = new THREE.BufferGeometry();
starGeometry.setAttribute(
"position",
new THREE.BufferAttribute(starPositions, 3),
);
const stars = new THREE.Points(
starGeometry,
new THREE.PointsMaterial({
color: 0xd8d4e8,
size: 0.11,
transparent: true,
opacity: 0.95,
sizeAttenuation: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}),
);
stars.renderOrder = 1;
scene.add(stars);
let time = 0;
let bobT = 0;
drawRef.current = (dt) => {
const l = live.current;
const o = orbit.current;
const step = Math.min(dt, 1 / 30);
const still = pausedRef.current || l.reducedMotion;
if (!still) {
time = (time + step) % 1000;
bobT = (bobT + step) % 1000;
if (!o.dragging && l.autoSpin) {
o.theta += l.spinSpeed * step;
o.phi = Math.max(PHI_MIN, Math.min(PHI_MAX, o.phi + o.vPhi * step));
const decay = Math.pow(0.92, step * 60);
o.vTheta *= decay;
o.vPhi *= decay;
}
o.theta %= Math.PI * 2;
if (l.parallax) {
const ease = Math.min(1, step * 4.5);
pointer.current.x += (pointer.current.tx - pointer.current.x) * ease;
pointer.current.y += (pointer.current.ty - pointer.current.y) * ease;
}
}
const parallaxOn = l.parallax && !l.reducedMotion ? 1 : 0;
const thetaEff = o.theta + pointer.current.x * 0.3 * parallaxOn;
const phiEff = Math.max(
PHI_MIN,
Math.min(PHI_MAX, o.phi - pointer.current.y * 0.18 * parallaxOn),
);
camera.position.setFromSphericalCoords(
RADIUS / Math.max(l.zoom, 0.2),
phiEff,
thetaEff,
);
monolith.scale.setScalar(l.scale);
const restY = -1.23 + (l.scale - 1) * 1.35;
if (l.reducedMotion) {
monolith.position.y = restY;
} else if (!still) {
monolith.position.y =
restY + l.floatAmp * (1 - Math.cos(bobT * 0.8));
}
target.set(0, restY + 0.35, 0);
camera.position.add(target);
camera.lookAt(target);
slabMaterial.color.set(l.slabColor);
slabMaterial.emissive.set(l.rimColor);
seamMaterial.color
.set(l.rimColor)
.multiplyScalar(
0.8 + 0.35 * Math.sin(time * 1.7) * (l.reducedMotion ? 0 : 1),
);
back.color.set(l.rimColor);
glowLight.color.set(l.sunColor);
glowLight.intensity = l.sun ? 0.9 : 0;
sunCore.visible = sunGlow.visible = sunCorona.visible = l.sun;
sunCore.material.color.set(l.sunColor);
sunGlow.material.color.set(l.sunColor);
sunCorona.material.color.set(l.sunColor);
(scene.background as THREE.Color).set(l.backgroundColor);
floorMaterial.uniforms.uTime.value = time;
floorMaterial.uniforms.uBg.value.set(l.backgroundColor);
floorMaterial.uniforms.uLine.value.set(l.rimColor);
renderer.render(scene, camera);
};
measureRef.current = ({ width, height, dpr }) => {
renderer.setPixelRatio(dpr);
renderer.setSize(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height)));
camera.aspect = Math.max(width, 1) / Math.max(height, 1);
camera.updateProjectionMatrix();
placeSun();
drawRef.current?.(0.016);
};
loop.resize();
loop.start();
return () => {
drawRef.current = null;
measureRef.current = null;
scene.traverse((node) => {
const mesh = node as THREE.Mesh;
if (mesh.geometry) mesh.geometry.dispose();
const material = mesh.material as
| THREE.Material
| THREE.Material[]
| undefined;
if (Array.isArray(material)) {
material.forEach((m) => m.dispose());
} else {
material?.dispose();
}
});
for (const s of [sunCore, sunGlow, sunCorona]) {
s.material.map?.dispose();
s.material.dispose();
}
renderer.dispose();
if (container.contains(canvas)) container.removeChild(canvas);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fallback]);
useEffect(() => {
loop.paint();
}, [slabColor, rimColor, sunColor, sun, backgroundColor, spinSpeed, floatAmp, zoom, autoSpin, loop]);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const o = orbit.current;
o.dragging = true;
o.pointerId = e.pointerId;
o.lastX = e.clientX;
o.lastY = e.clientY;
o.lastT = e.timeStamp;
o.vTheta = 0;
o.vPhi = 0;
e.currentTarget.setPointerCapture(e.pointerId);
loop.start();
};
const track = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return;
pointer.current.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.current.ty = -(((e.clientY - r.top) / r.height) * 2 - 1);
const o = orbit.current;
if (!o.dragging || e.pointerId !== o.pointerId) return;
const dx = e.clientX - o.lastX;
const dy = e.clientY - o.lastY;
const dt = (e.timeStamp - o.lastT) / 1000;
const dTheta = -dx * 0.005;
const dPhi = -dy * 0.004;
o.theta += dTheta;
o.phi = Math.max(PHI_MIN, Math.min(PHI_MAX, o.phi + dPhi));
if (dt > 0.001) {
o.vTheta = dTheta / dt;
o.vPhi = dPhi / dt;
}
o.lastX = e.clientX;
o.lastY = e.clientY;
o.lastT = e.timeStamp;
loop.start();
};
const release = () => {
pointer.current.tx = 0;
pointer.current.ty = 0;
orbit.current.dragging = false;
orbit.current.pointerId = -1;
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const o = orbit.current;
if (o.pointerId !== -1 && e.currentTarget.hasPointerCapture(o.pointerId)) {
e.currentTarget.releasePointerCapture(o.pointerId);
}
o.dragging = false;
o.pointerId = -1;
loop.start();
};
if (fallback) {
return (
<div
className={className ?? "relative h-full w-full overflow-hidden"}
style={{
backgroundColor,
backgroundImage: `radial-gradient(circle at 50% 42%, ${sunColor}44 0%, transparent 52%), linear-gradient(to bottom, ${backgroundColor}, ${rimColor}22)`,
}}
/>
);
}
return (
<div
ref={containerRef}
className={
className ??
"relative h-full w-full cursor-default overflow-hidden [&_canvas]:touch-none"
}
onPointerDown={onPointerDown}
onPointerMove={track}
onPointerUp={endDrag}
onPointerLeave={release}
onPointerCancel={endDrag}
/>
);
},
);
Monolith.displayName = "Monolith";
export default Monolith;