AETumi — AI-native 3D web platform for Three.js, WebGL and interactive websites
News & Guides

How to Synchronize DOM Content With a Three.js Scroll Scene

September 9, 2026 · AETumi

Key answer: synchronize DOM content with a Three.js scroll scene by running two independent systems, not one. The WebGL scene reads a normalized 0 to 1 progress value each frame; the HTML captions react to their own visibility through IntersectionObserver. Do not drive both from a single scroll handler — that is the tangle that breaks on resize and mobile. Position the canvas as a fixed or sticky layer, let real HTML sections scroll over it, toggle a class when each section enters view, and the 3D beats and text land together without fighting each other. This guide builds the canvas-plus-HTML layout, wires IntersectionObserver, aligns 3D moments to text, and keeps the whole thing crawlable and accessible.

Table of contents

Why two systems, not one

The instinct on a scroll page is to compute everything in one scroll listener — camera, objects and which caption is active. It always ends the same way: brittle offset math, captions that fire a beat late, and a layout that shatters the moment the viewport changes. The content and the scene have different natural inputs, and forcing them through one handler fights both.

From idea to productionAETumi technical diagram — From idea to productionServer-render contentLazy-load 3D bundleAdapt DPR & qualityDispose on route changeShip fast
From idea to production
VECTOR — Freight, reinvented
VECTOR — Freight, reinvented — live preview from the AETumi library

The maintainable pattern, and the one behind the 3D scroll hub, splits them by concern. The WebGL scene is continuous, so it reads a single progress value every frame. The HTML captions are discrete, so they react to their own visibility. Two small independent systems compose far more reliably than one large one.

The fixed-canvas, scrolling-HTML layout

The layout that makes syncing easy pins the canvas and lets the document scroll over it. The canvas is a full-viewport fixed layer; the content is normal flow on top.

AETumi live WebGL scene — real-time Three.js rendered in your browser, not a video.
<canvas id="scene"></canvas>            <!-- position: fixed, full viewport -->
<main>
  <section class="beat" data-beat="0"><h2>Meet AESPORT</h2></section>
  <section class="beat" data-beat="1"><h2>Carbon plate</h2></section>
  <section class="beat" data-beat="2"><h2>184 grams</h2></section>
</main>
One connected systemAETumi technical diagram — One connected system3D WEBSTACKNext.jsR3FThree.jsWebGLGSAPAssets
One connected system
#scene { position: fixed; inset: 0; z-index: 0; }
main    { position: relative; z-index: 1; }
.beat   { min-height: 100vh; }

With the canvas fixed, scrolling moves only the HTML, and the scene stays put while its progress advances. Each full-viewport section becomes one beat, which gives IntersectionObserver a clean target to watch.

Drive the scene from progress

The 3D side is the standard pipeline. Read scroll, normalize, ease, and render — the scene knows nothing about captions.

let target = 0, progress = 0
addEventListener('scroll', () => {
  const max = document.documentElement.scrollHeight - innerHeight
  target = max > 0 ? scrollY / max : 0
}, { passive: true })
function frame() {
  requestAnimationFrame(frame)
  progress += (target - progress) * 0.08
  updateScene(progress)          // camera, rotation, materials
  renderer.render(scene, camera)
}
frame()

This is deliberately identical to every other scene on the site. The scene's only contract is progress in, visuals out, which keeps it portable across Three.js projects and reusable as a component.

Drive captions with IntersectionObserver

The HTML side uses IntersectionObserver, which the browser optimizes far better than any scroll-position math you could write. Toggle a class as each beat enters and leaves view, and let CSS handle the transition.

const io = new IntersectionObserver((entries) => {
  for (const e of entries) e.target.classList.toggle('-active', e.isIntersecting)
}, { threshold: 0.55 })
document.querySelectorAll('.beat').forEach(el => io.observe(el))
.beat h2 { opacity: 0; transform: translateY(20px); transition: .5s; }
.beat.-active h2 { opacity: 1; transform: none; }

The observer fires only when visibility actually crosses the threshold, so captions animate in on their own schedule with zero cost on every other frame. This is the cheap, correct way to reveal text on a scroll page.

Aligning a 3D beat to a specific caption

Sometimes a 3D moment must land exactly when a caption appears — the shoe faces its sole precisely as the carbon-plate text arrives. Read the active beat's index inside the observer and nudge a scene target, letting the render loop ease toward it.

let beatTarget = 0
const io = new IntersectionObserver((entries) => {
  for (const e of entries) if (e.isIntersecting) beatTarget = +e.target.dataset.beat
}, { threshold: 0.55 })
// in updateScene, blend the continuous progress with the discrete beat as needed

This gives you both: continuous progress for the smooth camera and a discrete beat index for moments that must snap to text. The two inputs coexist because they never write to the same variable, the composability that keeps reusable 3D components clean.

Positioning HTML labels on 3D points

To pin an HTML label to a moving 3D point — a callout on a specific part — project the world position to screen coordinates each frame and move a DOM element there. Keep the label as HTML so it stays crisp and accessible.

const v = new THREE.Vector3()
function placeLabel(el, worldPos) {
  v.copy(worldPos).project(camera)
  el.style.transform =
    `translate(${(v.x * 0.5 + 0.5) * innerWidth}px, ${(-v.y * 0.5 + 0.5) * innerHeight}px)`
}

Projecting to screen space lets crisp HTML text track a 3D anchor without rendering text into the canvas, where it would blur and become uncrawlable. This is the standard technique for annotated product scenes on interactive websites.

Avoiding the single-scroll-handler trap

The anti-pattern to avoid is one scroll listener that computes camera position, reads element bounding rectangles, and toggles caption classes together. It couples the scene's timing to exact pixel offsets, forces layout reads that thrash performance, and breaks whenever the page reflows. Every symptom — late captions, jumpy text, mobile breakage — traces back to that coupling. Splitting into a progress-driven scene and an observer-driven DOM removes the whole class of bugs at once.

Performance

IntersectionObserver is nearly free because the browser batches its work off the main thread, so the caption system adds no per-frame cost. The scene follows the usual rules: cap devicePixelRatio, render on demand, and pause when the canvas is hidden. Reading element positions in a scroll handler, by contrast, forces synchronous layout every frame — the exact cost the observer is designed to eliminate. Keeping the two systems separate is a performance decision as much as an architectural one.

SEO and accessibility

Because the captions are real HTML sections with real headings, they are fully crawlable and keyboard-reachable regardless of the WebGL state. Mark the canvas aria-hidden, and under reduced motion skip the caption transitions so text appears immediately rather than animating.

if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
  document.querySelectorAll('.beat h2').forEach(el => el.style.opacity = 1)
}

The content is never trapped behind an animation, which is the accessibility baseline for any commercial project delivered for agencies.

How AETumi syncs DOM and 3D

AETumi is an AI-native 3D web platform for production Three.js and WebGL sites, React and Next.js components, and MCP workflows for assistants such as Claude Code. The AETumi standard for content sync is exactly this split: a progress-driven fixed canvas plus IntersectionObserver-driven HTML sections, with projected labels where a callout must track a 3D point. Because both systems are componentized, the layout can be scaffolded through an MCP workflow and filled with real content instead of hand-wired each time.

Technical proof on GitHub

The AETumiApp/threejs-scroll-animation repository includes the fixed-canvas layout and the progress pipeline described here, so you can see the scene and DOM systems working side by side. Use the repository for the runnable code and this article for the reasoning about why they stay separate.

How it works, step by stepAETumi technical diagram — How it works, step by stepNext.js (routing, SSR, image opt)React Three Fiber (declarative 3D)Three.js / WebGL (GPU rendering)GSAP / scroll (motion)
How it works, step by step

FAQ

How do I sync HTML captions with a Three.js scroll scene? Run two systems: drive the WebGL scene from a normalized progress value each frame, and toggle caption classes with IntersectionObserver based on visibility. Keep them independent so neither breaks the other.

Why not compute captions in the scroll handler? Reading element positions in a scroll handler forces synchronous layout every frame and couples timing to exact offsets, which breaks on resize and mobile. IntersectionObserver is batched off the main thread and far more reliable.

How do I pin an HTML label to a 3D point? Project the point's world position to screen space with vector.project(camera) each frame and set a CSS transform on the label element. Keep the label as HTML so it stays crisp and accessible.

Should the canvas scroll with the page? Usually not. Pin the canvas as a fixed full-viewport layer and let the HTML sections scroll over it, so the scene stays put while its progress advances.

Does this stay crawlable? Yes. The captions are real HTML sections with headings, fully indexable and keyboard-reachable, while the aria-hidden canvas only enhances them.

Continue with the 3D scroll hub, Three.js, React Three Fiber, reusable 3D components, interactive websites, the MCP workflow, and options for agencies.

Conclusion

Syncing DOM content with a 3D scroll scene is a discipline of separation: a progress-driven canvas and an observer-driven DOM, joined only where a beat must snap to text. Pin the canvas, let HTML scroll over it, react to visibility instead of scroll math, and project labels for callouts. Keep the two systems independent and the text and the 3D land together — reliably, crawlably, and without the single-handler tangle that sinks most attempts.

More from the AETumi library

Real, production-ready assets — preview the motion, grab the source.

Browse all 3D website templates →