*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #030510; color: #f0f4fb; font-family: 'Inter', 'SF Pro Display', system-ui, sans-serif; overflow: hidden; height: 100vh; }
#canvas-container { position: fixed; inset: 0; z-index: 0; }
#canvas-container canvas { display: block; width: 100%; height: 100%; }
.overlay {
position: fixed; top: 0; left: 0; right: 0; z-index: 2;
text-align: center; padding: 3rem 2rem; pointer-events: none;
}
.eyebrow { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.18em; color: #86e8ff; margin-bottom: 0.75rem; display: block; }
h1 { font-size: clamp(2rem, 5vw, 3.5rem); font-weight: 700; letter-spacing: -0.03em; text-shadow: 0 0 40px rgba(134,232,255,0.2); }
.subtitle { font-size: clamp(0.85rem, 1.5vw, 1rem); color: rgba(255,255,255,0.5); max-width: 420px; margin: 0.5rem auto 0; line-height: 1.5; }
.btn-back {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); z-index: 10;
padding: 0.6rem 1.5rem; border-radius: 999px;
border: 1px solid rgba(134,232,255,0.2); color: #86e8ff; text-decoration: none;
font: 600 0.8rem/1 'Inter', system-ui, sans-serif;
background: rgba(3,5,16,0.7); backdrop-filter: blur(8px); transition: all 0.25s;
}
.btn-back:hover { background: rgba(134,232,255,0.08); border-color: #86e8ff; }
if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import * as THREE from 'three';
initDemoShell({ title: 'Interactive 3D Scene', category: '3d', tech: ['three.js', 'instanced-mesh', 'raycaster'] });
let reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add('reduced-motion');
window.addEventListener('motion-preference', (e) => { reduced = e.detail.reduced; });
// Config
const GRID = 20; // 20x20 = 400 spheres
const SPACING = 0.6;
const REPULSION_RADIUS = 3;
const REPULSION_FORCE = 0.08;
const SPRING = 0.04;
const DAMPING = 0.88;
// Scene
const container = document.getElementById('canvas-container');
const scene = new THREE.Scene();
scene.background = new THREE.Color('#030510');
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 8, 10);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.AmbientLight(0x222244, 0.8));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(5, 8, 5);
scene.add(dirLight);
const pointLight = new THREE.PointLight(0x86e8ff, 2, 20);
pointLight.position.set(0, 3, 0);
scene.add(pointLight);
// Instanced mesh
const geo = new THREE.SphereGeometry(0.15, 16, 16);
const mat = new THREE.MeshStandardMaterial({ metalness: 0.6, roughness: 0.3 });
const count = GRID * GRID;
const mesh = new THREE.InstancedMesh(geo, mat, count);
scene.add(mesh);
// Particle state
const dummy = new THREE.Object3D();
const color = new THREE.Color();
const particles = [];
const halfGrid = (GRID - 1) * SPACING / 2;
for (let i = 0; i < GRID; i++) {
for (let j = 0; j < GRID; j++) {
const x = i * SPACING - halfGrid;
const z = j * SPACING - halfGrid;
particles.push({
homeX: x, homeZ: z,
x, y: 0, z,
vx: 0, vy: 0, vz: 0,
});
}
}
// Mouse → 3D plane intersection
const mouse = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
const intersectPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const mouseWorld = new THREE.Vector3();
document.addEventListener('mousemove', (e) => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
// Color palette
const coldColor = new THREE.Color('#86e8ff');
const warmColor = new THREE.Color('#ff40d6');
// Animate
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// Raycast mouse to ground plane
raycaster.setFromCamera(mouse, camera);
raycaster.ray.intersectPlane(intersectPlane, mouseWorld);
for (let i = 0; i < count; i++) {
const p = particles[i];
if (!reduced) {
// Distance from mouse
const dx = p.x - mouseWorld.x;
const dz = p.z - mouseWorld.z;
const dist = Math.sqrt(dx * dx + dz * dz);
// Repulsion
if (dist < REPULSION_RADIUS && dist > 0.01) {
const force = (1 - dist / REPULSION_RADIUS) * REPULSION_FORCE;
p.vx += (dx / dist) * force;
p.vz += (dz / dist) * force;
p.vy += force * 0.5; // push up too
}
// Spring back to home
p.vx += (p.homeX - p.x) * SPRING;
p.vz += (p.homeZ - p.z) * SPRING;
p.vy += (0 - p.y) * SPRING;
// Damping
p.vx *= DAMPING;
p.vy *= DAMPING;
p.vz *= DAMPING;
// Integrate
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
}
// Update instance
dummy.position.set(p.x, p.y, p.z);
const scale = 1 + p.y * 0.8; // scale with height
dummy.scale.setScalar(Math.max(scale, 0.3));
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
// Color by distance from mouse
const dist = Math.sqrt(
(p.x - mouseWorld.x) ** 2 + (p.z - mouseWorld.z) ** 2
);
const t2 = Math.min(dist / REPULSION_RADIUS, 1);
color.copy(warmColor).lerp(coldColor, t2);
mesh.setColorAt(i, color);
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
// Subtle camera sway
if (!reduced) {
camera.position.x = Math.sin(t * 0.15) * 0.5;
camera.lookAt(0, 0, 0);
}
renderer.render(scene, camera);
}
animate();
// Resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
window.addEventListener('beforeunload', () => { geo.dispose(); mat.dispose(); renderer.dispose(); });
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive 3D Scene — stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"/vendor/gsap/index.js","gsap/ScrollTrigger":"/vendor/gsap/ScrollTrigger.js","gsap/SplitText":"/vendor/gsap/SplitText.js","gsap/Flip":"/vendor/gsap/Flip.js","gsap/ScrambleTextPlugin":"/vendor/gsap/ScrambleTextPlugin.js","gsap/TextPlugin":"/vendor/gsap/TextPlugin.js","gsap/all":"/vendor/gsap/all.js","gsap/":"/vendor/gsap/","lenis":"/vendor/lenis/dist/lenis.mjs","three":"/vendor/three/build/three.module.js","three/addons/":"/vendor/three/examples/jsm/"}}</script>
</head>
<body>
<div id="canvas-container" aria-hidden="true"></div>
<div class="overlay">
<span class="eyebrow">Demo 10</span>
<h1>Interactive 3D</h1>
<p class="subtitle">A grid of 400 instanced spheres react to your mouse with repulsion physics and spring-based return. Colors shift by distance.</p>
</div>
<a href="/" class="btn-back">Back to Showcase</a>
<script type="module" src="script.js"></script>
</body>
</html>