All components

Brutalist Sci-fi Corridor

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

On page load a preloader counter climbs to 100 over a black overlay, then a GSAP timeline fades the overlay, rotates the Three.js camera around a sodium-lit brutalist concrete corridor (ACES grade, UnrealBloom + film-grain post-processing) and scrambles the nav and heading text into view via staggered opacity; afterward the camera follows the mouse with a lerped parallax.

How it's built

Category3d-webgl
Techgsap, three
Complexitypage
Performance costheavy
Mobile-safedesktop-first

three.js gsap webgl gltf bloom film-grain parallax brutalist preloader

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Brutalist Sci-fi Corridor — WebGL Preloader Reveal

Goal

Build a full-viewport WebGL hero: a brutalist concrete corridor rendered in Three.js with UnrealBloom + a custom film-grain shader, revealed by an on-load cinematic sequence. First a preloader counter climbs 0 → 100 over a solid black overlay while the GLTF model loads; then a single GSAP timeline fades the counter and overlay out, sweeps the camera 180° around the corridor to its resting angle, and scrambles the nav + heading text in via randomized per-character opacity flickers. Once the intro finishes, the camera follows the mouse with a lerped parallax sway. The overlaid HTML text uses mix-blend-mode: difference, so it reads as black over the bright, bloom-blown white scene and inverts as the camera moves.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) and three (npm). No GSAP plugins (no ScrollTrigger, no SplitText, no CustomEase — the text split is hand-rolled). No Lenis / no smooth-scroll (the page never scrolls). Import from three's examples/addons:

import gsap from "gsap";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";

Layout / HTML

Four stacked layers over the canvas host. Fictional brand name is "Astrolume" (use it or neutral text — no real brands).

<body>
  <div class="corridor"></div>            <!-- WebGL canvas is appended here by JS -->

  <div class="loading">Loading Scene</div> <!-- shown until GLTF finishes loading -->

  <div class="overlay">                     <!-- opaque black cover over the whole viewport -->
    <div class="counter"><p>0</p></div>     <!-- preloader number -->
  </div>

  <div class="hero">                        <!-- HTML UI layer, mix-blend-mode: difference -->
    <nav>
      <div class="logo"><a href="#">Astrolume</a></div>
      <div class="nav-items">
        <a href="#">Apparel</a><a href="#">Events</a><a href="#">Archive</a>
      </div>
      <div class="site-year"><p>2024 [N]</p></div>
    </nav>
    <h1>Blending contemporary minimalism with futuristic innovation to create designs that transcend trends and define elegance.</h1>
    <div class="footer"><p>/ Made by Astrolume</p></div>
  </div>

  <script type="module" src="./script.js"></script>
</body>

Styling

  • Reset * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; background-color:#0f0f0f; } (the CSS body bg only shows for a blink; the WebGL scene background is white).
  • .corridor { position:absolute; top:0; left:0; width:100vw; height:100vh; } — hosts renderer.domElement.
  • .loading { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); text-transform:uppercase; font-family:"PP Neue Montreal"; font-size:13px; color:#fff; } (any clean sans fallback is fine).
  • .overlay { position:fixed; inset:0; width:100vw; height:100vh; background-color:#000; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:1em; } — the black preloader cover. .counter p { color:#fff; }.
  • h1 { position:absolute; bottom:1em; left:1em; width:60%; text-transform:uppercase; font-family:"LomoCopy Lt Std"; color:#fff; user-select:none; } — big display serif/display face bottom-left (any bold display fallback is fine).
  • p, a { text-decoration:none; text-transform:uppercase; font-family:"Akkurat Mono", monospace; font-size:12px; color:#fff; } — small mono UI text.
  • .hero { position:absolute; inset:0; width:100vw; height:100vh; z-index:2; mix-blend-mode:difference; }critical: this is what makes the white UI text invert to black over the white/bloomed scene and shift as the camera pans.
  • nav { position:absolute; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; }. .nav-items { display:flex; gap:2em; }. .footer { position:absolute; right:2em; bottom:2em; }.

The effect (be exhaustive)

Renderer / camera / scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);            // bright white backdrop (bloom blows it out)
const camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ powerPreference:"high-performance", antialias:false, stencil:false, depth:false });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.NoToneMapping;
document.querySelector(".corridor").appendChild(renderer.domElement);
Lights
  • AmbientLight(0xffffff, 0.5).
  • DirectionalLight(0xffffff, 0.5) at (5,8,5), castShadow = true (key).
  • DirectionalLight(0x000000, 0.5) at (-5,3,-5) (black fill → effectively no fill, kept for parity).
  • PointLight(0xffffff, 2, 1) at (2,3,2) and another at (-2,3,-2) (distance 1, so very localized hot spots).
Camera orbit geometry (constants)
const initialAngle = Math.PI / 4;      // 45° — the resting angle
const radius       = Math.sqrt(50);    // ≈ 7.071
let currentAngle   = initialAngle + Math.PI;  // start 180° opposite (≈225°)
let targetAngle    = initialAngle;
let currentY = 0, targetY = 0;

Camera always sits on a horizontal circle of radius around the origin and looks at (0,0,0): camera.position.x = Math.cos(currentAngle)*radius; camera.position.z = Math.sin(currentAngle)*radius; camera.position.y = currentY; camera.lookAt(0,0,0); So the resting shot is (5, 0, 5) and the start shot is (-5, 0, -5).

GLTF load + material rebuild

Load a brutalist corridor GLTF (path /c/brutalist-corridor/scene.gltf). In the callback, model.traverse every mesh:

  • child.castShadow = child.receiveShadow = true.
  • Pick an emissive color by mesh-name substring from { screen:0x00ff00, lamp:0xffaa00, light:0xffffff, default:0xffffff } (first key whose lowercase name is includes-matched wins; else default). This single-mesh corridor matches none, so all meshes get white — and emissiveIntensity is 0 anyway, so emissive is inert here; keep the logic for parity.
  • Replace each material with:
new THREE.MeshStandardMaterial({
  color: child.material.color,
  map: child.material.map,
  emissive: emissiveColor,
  emissiveIntensity: 0,
  roughness: 5.0,     // clamped to 1 internally → fully matte concrete
  metalness: 0.125,
});
  • If there's a map: map.encoding = THREE.sRGBEncoding; map.flipY = false;.
  • After traversal, recenter the model: const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); then scene.add(model).
  • Hide .loading (display:none) and call startAnimations().
Hand-rolled text split (run once at module load, before the model finishes)

splitText() over document.querySelectorAll("nav a, nav p, h1, .footer p"). For each element: uppercase its text, clear it, then rebuild character-by-character:

  • Space → <span class="space"> with inline styles display:inline-block; width:15px; opacity:0.
  • Any other char → <span class="char">CHAR</span> with display:inline-block; opacity:0.

Every glyph therefore starts invisible; the reveal animates these .char/.space spans.

The intro GSAP timeline (startAnimations, fired after GLTF load)

const timeline = gsap.timeline({ onComplete: () => { animationComplete = true; } }); — the onComplete flag is what later enables mouse parallax.

  1. Counter tween (0 → 100). First build a checkpoints array of ascending integers: start [0], numJumps = 7; loop while checkpoints.length < 7 computing maxJump = Math.floor((100 - currentValue)/(numJumps - checkpoints.length + 1)) * 2, jump = 5 + Math.floor(Math.random()*(maxJump - 5)), currentValue += jump, and push it only if < 97; finally push(97); push(100);. Then:
timeline.to({}, {
  duration: 4, ease: "none",
  onUpdate: function () {
    const p = this.progress();
    const idx = Math.floor(p * checkpoints.length);
    if (idx !== currentIndex && idx < checkpoints.length) { currentIndex = idx; counter.textContent = checkpoints[idx]; }
  },
  onComplete: function () { counter.textContent = "100"; },
});

So over 4s of linear time the number jumps through the random checkpoints and lands on 100.

  1. Fade the counter out: timeline.to(".counter", { opacity:0, duration:0.75, ease:"power2.out" }, "+=0.2"); (0.2s after the counter tween ends).
  1. Camera 180° sweep (0.2s after the counter fades in the timeline sequence) via a proxy object so GSAP can ease the angle:
const rotationProxy = { angle: currentAngle };      // ≈225°
timeline.to(rotationProxy, {
  angle: initialAngle,                               // → 45°
  duration: 2, ease: "power2.inOut",
  onUpdate: () => {
    currentAngle = rotationProxy.angle;
    camera.position.x = Math.cos(currentAngle)*radius;
    camera.position.z = Math.sin(currentAngle)*radius;
    camera.lookAt(0,0,0);
  },
}, "+=0.2");
  1. Overlay fade-out, concurrent with the sweep (position "<" = start together): timeline.to(overlay, { opacity:0, duration:1.5, ease:"power2.inOut", onComplete:()=>overlay.remove() }, "<"); — the black cover dissolves to reveal the corridor as the camera orbits.
  1. Text scramble-in (added at "-=1", i.e. overlapping the last second of the sweep) as a nested timeline returned from timeline.add(fn, "-=1"). Grab const allChars = document.querySelectorAll(".char, .space") and run three staggered opacity passes — all animate to opacity:1, ease:"power2.inOut", with from:"random" staggers whose yoyo/repeat make characters flicker on/off before settling on:
tl.to(allChars, { duration:0.1, opacity:1, ease:"power2.inOut", stagger:{ amount:1, each:0.1, from:"random", repeat:2, yoyo:true } });
tl.to(allChars, { duration:0.1, opacity:1, ease:"power2.inOut", stagger:{ amount:1, each:0.1, from:"random", repeat:1, yoyo:true } });
tl.to(allChars, { duration:0.15, opacity:1, ease:"power2.inOut", stagger:{ amount:1, each:0.2, from:"random" } });

The first two passes (with yoyo:true + repeat) make glyphs blink on and off in random order (a "boot-up / scramble" feel); the final pass with no yoyo latches them all fully visible.

Post-processing chain
const renderScene = new RenderPass(scene, camera);
const bloomPass   = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 2.0, 0.25, 0.5); // strength 2.0, radius 0.25, threshold 0.5
const composer    = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
composer.addPass(filmGrainPass);   // renderToScreen = true

The strong bloom (strength 2.0) over the white background gives the corridor its hazy, overexposed sci-fi glow.

Custom film-grain ShaderPass (filmGrainPass.renderToScreen = true) — uniforms { tDiffuse:null, time:0, amount:0.15, speed:2.0, size:1.0 }; passthrough vertex shader; fragment adds animated noise:

float random(vec2 co){ return fract(sin(dot(co.xy, vec2(12.9898,78.233))) * 43758.5453); }
void main(){
  vec4 color = texture2D(tDiffuse, vUv);
  vec2 position = vUv * size;
  float grain = random(position * time * speed);
  color.rgb += grain * amount;          // additive grain
  gl_FragColor = color;
}
Render loop + mouse parallax
function lerp(a,b,t){ return a + (b-a)*t; }
function animate(){
  requestAnimationFrame(animate);
  filmGrainPass.uniforms.time.value = performance.now() * 0.001;   // grain always animates
  if (animationComplete) {                                         // parallax only after intro
    currentAngle = lerp(currentAngle, targetAngle, 0.025);
    currentY     = lerp(currentY, targetY, 0.025);
    camera.position.x = Math.cos(currentAngle)*radius;
    camera.position.z = Math.sin(currentAngle)*radius;
    camera.position.y = lerp(camera.position.y, currentY, 0.05);
  }
  camera.lookAt(0,0,0);
  composer.render();
}
animate();

Mouse handler (guarded by if (!animationComplete) return;): mouseX = (clientX - innerWidth/2)/(innerWidth/2), mouseY = (clientY - innerHeight/2)/(innerHeight/2), then targetAngle = initialAngle + (-mouseX * 0.35) (≈ ±0.35 rad horizontal sway) and targetY = -mouseY * 1.5 (vertical rise/fall). The lerp factors (0.025 / 0.05) give the parallax a heavy, floaty follow.

Resize

On window.resize: camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); composer.setSize(innerWidth, innerHeight);.

Assets / images

No photographic images. The only asset is a single GLTF model of a brutalist concrete corridor / interior (scene.gltf + scene.bin + a textures/ folder), one mesh, one PBR material named "Concrete Tiles" with four maps: baseColor (grey cast-concrete tiles), metallicRoughness, normal, and an emissive map. Point the loader at /c/brutalist-corridor/scene.gltf. Any comparable interior/corridor GLTF with concrete tile textures works; the material is rebuilt to matte (roughness 5.0, metalness 0.125) so surface detail comes from the normal/baseColor maps, not gloss. The scene is auto-centered at the origin, so the exact model bounds don't matter.

Behavior notes

  • Autoplay once on load. Sequence: "Loading Scene" until the GLTF resolves → counter 0→100 (~4s) → counter fade → 2s camera sweep with concurrent overlay dissolve → text scramble-in (overlapping the last 1s) → animationComplete = true. Full intro is roughly 8–9s.
  • Mouse parallax is enabled only after the intro completes; before that, mousemove is ignored.
  • Desktop-oriented, WebGL-heavy (GLTF + UnrealBloom + custom shader pass): not mobile-safe; the parallax is pointer-driven. No touch fallback in the original.
  • No scroll, no scroll hijack, no ScrollTrigger — the page is a single fixed viewport.
  • No reduced-motion branch in the original; grain + parallax run continuously via requestAnimationFrame.