Hero occlusion
Scroll to cover the hero ↓
02 — The covering section
Covering the sticky hero
Scroll slowly across the edge, fling past it, then come back. The hero leans back as it disappears, and returns as the covering section scrolls away.
Compare both paths in the Inspector: Settings → Renderer → Force WebGL. This panel covers the hero on both backends. WebGL keeps extra pixels behind it with padded scissor clipping. Alt + D opens the canvas controls.
Register the hero ref with views.addView in a React effect. Pass a live ThreeDOM instance and your loaded scene and camera. The scrolling clip ref applies only to the shared WebGL canvas.
"use client"
import { useEffect, useRef } from "react"
import type { ThreeDOM } from "portalgl/three"
import type { Scene, PerspectiveCamera } from "three/webgpu"
type Props = { views: ThreeDOM; scene: Scene; camera: PerspectiveCamera }
export default function HeroView({ views, scene, camera }: Props) {
const hero = useRef<HTMLDivElement>(null)
const clip = useRef<HTMLDivElement>(null)
useEffect(() => {
const view = views.addView(hero.current!, {
scene, camera,
clip: views.sharedCanvas ? clip.current! : undefined,
})
return () => view.destroy()
}, [views, scene, camera])
return (
<>
<div className="relative h-[200svh]">
<div ref={clip} className="absolute inset-x-0 top-0 h-svh" />
<section className="sticky top-0 z-0 h-svh bg-lime-300">
<div ref={hero} className="h-full" />
</section>
</div>
<section className="relative flow-root -mt-[100svh] bg-background">
<div className="relative z-20 min-h-svh bg-background p-6">
This panel covers the hero.
</div>
</section>
</>
)
}Manual JavaScript: view clipping
The wrapper uses the clipping element only for the shared WebGL canvas. With your own initialized views, scene, and camera:
const hero = views.addView(heroElement, {
scene, camera,
clip: views.sharedCanvas
? document.querySelector('#hero-window')
: undefined,
})
await views.prepare() // Draw all sized views, then reveal them together.
// Start the normal animation loop after preparation.
// A 200svh wrapper bounds the 100svh sticky hero.
// The next section overlaps by 100svh; the clip window scrolls away.
// WebGPU retains the full hero beneath the covering section.
// WebGL pads the scissor; the HTML panel covers the extra pixels.Other views keep rendering
Twenty-four bunny scenes share the renderer. Each one enters and leaves on its own; the hero’s clipping window only affects the hero.
03 — How the layers work
Canvas and HTML stacking order
In WebGL, the canvas is outside <main>. It can still appear above the hero and below the covering panel because DOM nesting and visual layering are different things.
The actual WebGL DOM
The demo prepends one canvas to its outer container. The tree below omits layout wrappers; each PageContainer is an ordinary <div>. The canvas is fixed while the hero is pinned and switches to absolute positioning for the scrolling gallery. Its layer stays at 15. The shared site header sits outside this demo container.
<div> Demo container
│
├── <canvas> position: fixed; z-index: 15
│ Suzanne AND all the bunnies
│
└── <main> position: relative; z-index: auto
│
├── <div data-hero-scroll> 200svh tall
│ ├── #hero-window Invisible clipping reference
│ └── <section data-hero> position: sticky; z-index: 0
│ ├── Hero heading
│ └── [data-tracked] Empty Suzanne placeholder
│
└── <section data-cover> position: relative; z-index: auto
├── [data-cover-panel] position: relative; z-index: 20
│ ├── Explanation
│ └── Code block
├── Gallery z-index: auto
│ └── [data-tracked] Empty bunny placeholders
└── This explanationWhy the nested panel can cover the outside canvas
A stacking context groups an element and its descendants into one inseparable visual layer. Neither main nor data-cover creates one here: position: relative with z-index: auto does not create that boundary, and neither does flow-root.
The browser can therefore compare the nested panel’s layer 20 with the outside canvas’s layer 15. The DOM above paints in this order:
FRONT · z-index: 20
Covering HTML panel
Its opaque background hides the canvas wherever they overlap.
MIDDLE · z-index: 15
One shared canvas
Suzanne and the bunnies. Transparent wherever no model is drawn.
BACK · hero z-index: 0 / backgrounds: auto
Hero HTML and gallery backgrounds
Visible through the transparent parts of the canvas.
Only the text-and-code panel gets z-index: 20. Giving that layer to the whole covering section would also lift the opaque gallery backgrounds above the canvas, hiding the bunnies.
Stacking context constraints
Adding z-index: 0 to the positioned main element would create a stacking context. Its children would then be confined below the outside canvas:
canvas: 15 ABOVE the entire main layer
main: 0 New stacking context
└── covering panel: 20 Only ranks inside main.
Cannot rise above the canvas.The hero already has this boundary. Giving a child inside the hero z-index: 20would still confine it to the hero’s layer 0. The shared site header lives outside that boundary.
The placeholders position the models
Each empty data-tracked element supplies a position and size. JavaScript draws its model at those coordinates on the shared canvas. The placeholder determines where the model appears; CSS layering determines which HTML covers it.
The scissor is separate. It limits Suzanne’s rendered region using the scrolling hero-window rectangle, with 25% padding by default. The opaque HTML panel hides the padded edge as it moves upward.
WebGPU puts the canvases inside their hosts
WebGPU uses the same renderer with a separate local canvas for each view. There is no shared outside canvas at layer 15 in this mode:
<main>
├── Hero section z-index: 0
│ ├── Hero heading
│ └── [data-tracked]
│ └── <canvas> Suzanne, inside her host
│
└── Covering section z-index: auto
├── Covering panel z-index: 20
└── Gallery
└── [data-tracked] Tile background
└── <canvas> Bunny, inside its tileSuzanne’s canvas belongs to the hero’s layer, so the panel naturally covers it. Each bunny canvas sits above its own tile background. The hero uses no explicit scissor in this mode: its full image remains underneath the covering HTML.