A Pen by James Drew on CodePen.
Created
May 10, 2026 13:03
-
-
Save jdrew1303/24a9b2c2c12c23f78766b7f7c73af8fc to your computer and use it in GitHub Desktop.
three_site_demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en" data-color-scheme="light"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> | |
| <title>Three.js High Fidelity Scaffold</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <style> | |
| /* ========================================================================== | |
| 1. DESIGN TOKENS & BASE (Kept from original) | |
| ========================================================================== */ | |
| :root { | |
| --page-border: #dcdcdc; | |
| --page-background: #ffffff; | |
| --page-text: #121212; | |
| --page-max-width: 100%; | |
| } | |
| @media (min-width: 46.25em) { :root { --page-max-width: 740px; } } | |
| @media (min-width: 61.25em) { :root { --page-max-width: 980px; } } | |
| @media (min-width: 71.25em) { :root { --page-max-width: 1140px; } } | |
| @media (min-width: 81.25em) { :root { --page-max-width: 1300px; } } | |
| body { | |
| margin: 0; padding: 0; | |
| background-color: #f6f6f6; | |
| color: var(--page-text); | |
| font-family: 'JetBrains Mono', 'Menlo', 'Cascadia Code', monospace; | |
| font-variant-ligatures: contextual; | |
| letter-spacing: -0.02em; | |
| line-height: 1.6; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| h1, h2 { font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; } | |
| /* ========================================================================== | |
| 2. PAGE CONTAINER & HERO | |
| ========================================================================== */ | |
| .page-container { | |
| position: relative; | |
| margin: 0 auto; | |
| max-width: var(--page-max-width); | |
| background-color: var(--page-background); | |
| border-left: 1px solid var(--page-border); | |
| border-right: 1px solid var(--page-border); | |
| box-sizing: border-box; | |
| } | |
| @media (min-width: 61.25em) { | |
| .page-container::after { | |
| content: ""; position: absolute; top: 0; bottom: 0; left: 50%; | |
| width: 1px; background-color: var(--page-border); z-index: 5; pointer-events: none; | |
| } | |
| } | |
| .article-hero { | |
| position: relative; | |
| background-color: #000; | |
| border-bottom: 1px solid var(--page-border); | |
| isolation: isolate; z-index: 10; | |
| } | |
| .article-hero::before { | |
| content: ""; position: absolute; inset: 0; | |
| background: transparent; z-index: 6; pointer-events: none; | |
| } | |
| .hero-media { | |
| position: sticky; top: 0; height: 100vh; width: 100%; | |
| will-change: transform; overflow: hidden; | |
| background: #050810; /* Dark backdrop for the 3D scene */ | |
| } | |
| /* Three.js Canvas adjustments */ | |
| .hero-media canvas, .three-mount canvas { | |
| display: block; width: 100% !important; height: 100% !important; | |
| opacity: 0.8; | |
| } | |
| .hero-furniture { | |
| position: relative; z-index: 10; margin-top: -100vh; | |
| pointer-events: none; padding-bottom: 10vh; | |
| } | |
| .hero-furniture > * { pointer-events: auto; } | |
| .hero-headline { padding-top: 45vh; padding-left: 20px; } | |
| .hero-headline h1 { color: #fff; margin: 0 0 24px 0; padding-top: 12px; max-width: 500px; border-top: 6px solid #fff; } | |
| .hero-headline h1 span { font-weight: 700; line-height: 1; display: block; } | |
| .hero-headline h1 span:first-of-type { font-size: clamp(2.5rem, 5vw, 4rem); padding-bottom: 10px; } | |
| .hero-headline h1 span:last-of-type { font-size: clamp(1.2rem, 2vw, 1.7rem); font-weight: 500; } | |
| .hero-standfirst { padding: 0 20px 20vh 20px; max-width: 600px; } | |
| .hero-standfirst p { font-size: 1.4rem; line-height: 1.3; color: #fff; } | |
| @media (min-width: 71.25em) { | |
| .hero-headline, .hero-standfirst { padding-left: 60px; } | |
| } | |
| /* ========================================================================== | |
| 3. SCROLLYTELLING SECTIONS | |
| ========================================================================== */ | |
| .scroll-section { position: relative; border-bottom: 1px solid var(--page-border); } | |
| @media (min-width: 61.25em) { | |
| .scroll-section { | |
| display: grid; grid-template-columns: 1fr 1fr; grid-template-areas: "text media"; | |
| } | |
| } | |
| .scroll-text { padding: 10vh 20px; z-index: 10; position: relative; } | |
| @media (min-width: 61.25em) { | |
| .scroll-text { grid-area: text; padding: 15vh 60px 25vh 60px; } | |
| } | |
| .scroll-media { | |
| position: sticky; top: 0; height: 100vh; z-index: 1; | |
| overflow: hidden; background-color: #000; | |
| opacity: 0; transition: opacity 1s ease-in-out; | |
| will-change: opacity; | |
| display: flex; flex-direction: column; | |
| } | |
| .scroll-media.is-visible { opacity: 1; } | |
| @media (min-width: 61.25em) { .scroll-media { grid-area: media; } } | |
| /* The container for the Three.js canvases in the panels */ | |
| .three-mount { | |
| flex: 1; width: 100%; height: 100%; | |
| overflow: hidden; box-sizing: border-box; | |
| background: #0a0e1a; | |
| } | |
| /* Typography */ | |
| .section-heading h2 { font-size: clamp(2rem, 4vw, 3.2rem); margin: 0 0 30px 0; line-height: 1; } | |
| .body-text { font-size: 1.25rem; line-height: 1.6; margin-bottom: 1.5rem; max-width: 500px; } | |
| .media-caption { | |
| font-size: 0.8rem; background: rgba(18, 18, 18, 0.8); color: #fff; | |
| padding: 8px 12px; position: absolute; bottom: 0; right: 0; z-index: 10; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="page-container"> | |
| <header class="article-hero"> | |
| <div class="hero-media" id="hero-mount"></div> | |
| <div class="hero-furniture"> | |
| <div class="hero-headline"> | |
| <h1> | |
| <span>A war foretold:</span> | |
| <span>how the CIA and MI6 got hold of Putin's Ukraine plans</span> | |
| </h1> | |
| </div> | |
| <div class="hero-standfirst"> | |
| <p>Drawing on more than 100 interviews with senior intelligence officials, this account details how the West uncovered Vladimir Putin's plans to invade.</p> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>Putin starts planning</h2> | |
| </div> | |
| <p class="body-text">The CIA discovered an awful lot about Putin's plans to invade Ukraine, but one thing they never worked out for sure is when he first made up his mind to go all-in.</p> | |
| <p class="body-text">During those months, Putin passed constitutional amendments to ensure he could stay in power beyond 2024. Then, locked away in isolation for months during Covid, he devoured books on Russian history and pondered his own place in it.</p> | |
| </div> | |
| <div class="scroll-media" id="panel-1"> | |
| <div class="three-mount" id="mount-1"></div> | |
| <figcaption class="media-caption">Fig 1. Component assembly structure.</figcaption> | |
| </div> | |
| </div> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>The final warnings</h2> | |
| </div> | |
| <p class="body-text">On 22 February, the day after Putin's theatrical performance, Ukraine's own security council met in Kyiv. Zaluzhnyi tried to canvass support for martial law.</p> | |
| <p class="body-text">A few hours later, Zelenskyy was handed a red folder containing a top-secret intelligence report about a "direct physical threat" to the president.</p> | |
| </div> | |
| <div class="scroll-media" id="panel-2"> | |
| <div class="three-mount" id="mount-2"></div> | |
| <figcaption class="media-caption">Fig 2. Spatial analysis view.</figcaption> | |
| </div> | |
| </div> | |
| <div class="scroll-section"> | |
| <div class="scroll-text"> | |
| <div class="section-heading"> | |
| <h2>See it in practice</h2> | |
| </div> | |
| <p class="body-text">The system draws on the local development plan, national planning policy, and case history. The officer stays in control; the model accelerates the legwork.</p> | |
| <p class="body-text">This is not a chatbot bolted on to a website. It is a deliberate, audited workflow — with clear escalation paths and a paper trail regulators can follow.</p> | |
| </div> | |
| <div class="scroll-media" id="panel-3"> | |
| <div class="three-mount" id="mount-3"></div> | |
| <figcaption class="media-caption">Fig 3. Topographic extrapolation.</figcaption> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // ========================================================================= | |
| // THREE.JS BOILERPLATE & MANAGER | |
| // ========================================================================= | |
| // Store all active Three.js instances to manage resizing and animation | |
| const threeInstances = []; | |
| /** | |
| * Helper function to build a standard Three.js scene. | |
| * You can eventually replace the geometry with GLTFLoader for CAD models. | |
| */ | |
| function createThreeScene(mountElement, customGeometry, wireframeColor) { | |
| // 1. Setup Scene | |
| const scene = new THREE.Scene(); | |
| // 2. Setup Camera | |
| const camera = new THREE.PerspectiveCamera(75, mountElement.clientWidth / mountElement.clientHeight, 0.1, 1000); | |
| camera.position.z = 5; | |
| // 3. Setup Renderer | |
| const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); | |
| renderer.setSize(mountElement.clientWidth, mountElement.clientHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| mountElement.appendChild(renderer.domElement); | |
| // 4. Add Lights | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1); | |
| directionalLight.position.set(5, 5, 5); | |
| scene.add(directionalLight); | |
| // 5. Add Object (Placeholder CAD look: Solid core with colored wireframe) | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0x222222, | |
| roughness: 0.5, | |
| metalness: 0.8 | |
| }); | |
| const wireMaterial = new THREE.MeshBasicMaterial({ | |
| color: wireframeColor || 0x00e5a0, | |
| wireframe: true | |
| }); | |
| const mesh = new THREE.Mesh(customGeometry, material); | |
| const wireMesh = new THREE.Mesh(customGeometry, wireMaterial); | |
| // Slightly scale up wireframe to prevent z-fighting | |
| wireMesh.scale.set(1.01, 1.01, 1.01); | |
| mesh.add(wireMesh); | |
| scene.add(mesh); | |
| // Return the instance data | |
| return { | |
| mount: mountElement, | |
| scene, | |
| camera, | |
| renderer, | |
| mesh, | |
| isRunning: false, | |
| animationFrameId: null | |
| }; | |
| } | |
| /** | |
| * The render loop for a specific scene | |
| */ | |
| function animateScene(instance) { | |
| if (!instance.isRunning) return; | |
| instance.animationFrameId = requestAnimationFrame(() => animateScene(instance)); | |
| // Slow rotation to simulate a CAD turntable | |
| instance.mesh.rotation.x += 0.005; | |
| instance.mesh.rotation.y += 0.005; | |
| instance.renderer.render(instance.scene, instance.camera); | |
| } | |
| /** | |
| * Start/Stop functions to be called by Intersection Observer | |
| */ | |
| function playScene(instance) { | |
| if (!instance.isRunning) { | |
| instance.isRunning = true; | |
| animateScene(instance); | |
| } | |
| } | |
| function pauseScene(instance) { | |
| if (instance.isRunning) { | |
| instance.isRunning = false; | |
| cancelAnimationFrame(instance.animationFrameId); | |
| } | |
| } | |
| // ========================================================================= | |
| // INITIALIZE SCENES | |
| // ========================================================================= | |
| // HERO: Big Torus Knot | |
| const heroInstance = createThreeScene( | |
| document.getElementById('hero-mount'), | |
| new THREE.TorusKnotGeometry(1.5, 0.4, 128, 16), | |
| 0x4fc3f7 // Blue wireframe | |
| ); | |
| threeInstances.push(heroInstance); | |
| // PANEL 1: Box | |
| const p1Instance = createThreeScene( | |
| document.getElementById('mount-1'), | |
| new THREE.BoxGeometry(2, 2, 2), | |
| 0x00e5a0 // Green wireframe | |
| ); | |
| threeInstances.push({ ...p1Instance, panelId: 'panel-1' }); | |
| // PANEL 2: Sphere | |
| const p2Instance = createThreeScene( | |
| document.getElementById('mount-2'), | |
| new THREE.SphereGeometry(1.5, 16, 16), | |
| 0xffb300 // Amber wireframe | |
| ); | |
| threeInstances.push({ ...p2Instance, panelId: 'panel-2' }); | |
| // PANEL 3: Cone | |
| const p3Instance = createThreeScene( | |
| document.getElementById('mount-3'), | |
| new THREE.ConeGeometry(1.5, 3, 16), | |
| 0xff5252 // Red wireframe | |
| ); | |
| threeInstances.push({ ...p3Instance, panelId: 'panel-3' }); | |
| // ========================================================================= | |
| // OBSERVERS (Visibility & Animation Control) | |
| // ========================================================================= | |
| // Observer for CSS fade-in | |
| const fadeObserver = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) e.target.classList.add('is-visible'); | |
| }); | |
| }, { threshold: 0.1 }); | |
| document.querySelectorAll('.scroll-media').forEach(f => fadeObserver.observe(f)); | |
| // Observer for Three.js rendering (Pause when off-screen to save battery/GPU) | |
| const renderObserver = new IntersectionObserver(entries => { | |
| entries.forEach(entry => { | |
| // Find the specific instance associated with this DOM element | |
| const instance = threeInstances.find(inst => | |
| inst.mount === entry.target || inst.panelId === entry.target.id | |
| ); | |
| if (instance) { | |
| if (entry.isIntersecting) { | |
| playScene(instance); | |
| } else { | |
| pauseScene(instance); | |
| } | |
| } | |
| }); | |
| }, { threshold: 0.05 }); // Start rendering just before it fully comes into view | |
| // Observe the Hero | |
| renderObserver.observe(document.getElementById('hero-mount')); | |
| // Observe the Panels | |
| renderObserver.observe(document.getElementById('panel-1')); | |
| renderObserver.observe(document.getElementById('panel-2')); | |
| renderObserver.observe(document.getElementById('panel-3')); | |
| // ========================================================================= | |
| // RESIZE HANDLER | |
| // ========================================================================= | |
| window.addEventListener('resize', () => { | |
| threeInstances.forEach(instance => { | |
| const width = instance.mount.clientWidth; | |
| const height = instance.mount.clientHeight; | |
| instance.renderer.setSize(width, height); | |
| instance.camera.aspect = width / height; | |
| instance.camera.updateProjectionMatrix(); | |
| // Force a render frame immediately on resize so it doesn't look blank | |
| // if it's currently paused but being resized. | |
| if (!instance.isRunning) { | |
| instance.renderer.render(instance.scene, instance.camera); | |
| } | |
| }); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment