# How to create a scrollable plane with clipping in Three.js

**URL:** <https://discourse.threejs.org/t/how-to-create-a-scrollable-plane-with-clipping-in-three-js/81171>\
**Category:** Questions\
**Tags:** raycaster\
**Created:** [April 14, 2025, 4:57am UTC](https://discourse.threejs.org/t/how-to-create-a-scrollable-plane-with-clipping-in-three-js/81171 "2025-04-14T04:57:46Z")\
**Posts on this page:** 3\
**Page:** 1

<div class="post-metadata">

**Author:** ![wildy13](https://yyz1.discourse-cdn.com/flex035/user_avatar/discourse.threejs.org/wildy13/32/53564_2.png) [@wildy13](https://discourse.threejs.org/u/wildy13)\
**Post date:** [April 14, 2025, 4:57am UTC](https://discourse.threejs.org/t/how-to-create-a-scrollable-plane-with-clipping-in-three-js/81171/1 "2025-04-14T04:57:46Z")

</div>

Hello everyone,

I’m working on a project where I need to create a **scrollable plane** in Three.js that contains multiple items (such as planes and text) as children. I want the content within this plane to be scrollable, and I also need to **clip** the content when it exceeds the boundaries of the panel (plane). Specifically, I want the content to stop scrolling once it reaches a minimum or maximum height, and anything outside of that range should be clipped.

Here’s what I’ve tried so far:

1. I created a plane using `THREE.PlaneGeometry` for the panel, and then added visual items (such as smaller planes and text) as children of the panel.
2. I used **clipping planes** to try to hide any content that is outside the bounds of the panel.

The issue I’m facing is:

- Even though I’ve set the `clippingPlanes` for the material, some of the objects are still visible outside the bounds of the scrollable area.
- I want the scrolling to be constrained within the panel, and the content should be clipped based on the panel’s size and position.

Here’s a simplified version of my code:

```js
import * as THREE from 'three';
import {Text} from 'troika-three-text';
import { System, Component, Types, World } from "three/addons/libs/ecsy.module.js";
import Stats from 'three/addons/libs/stats.module.js';
import { PerformanceHelper } from './js/PerformanceHelper.js';
import { XRControllerModelFactory } from 'three/addons/Addons.js';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { Announcement } from './js/Announcement.js'

class App {
    constructor() {
        this.manager = null;
        this.clock = new THREE.Clock();

        this.scene = new THREE.Scene();
        this.scene.background = new THREE.Color(0x2f2f2f);

        const capsuleGeometry = new THREE.CapsuleGeometry(0.3, 0.6, 10, 10);
        const capsuleMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, wireframe: true });
        this.cameraCapsule = new THREE.Mesh(capsuleGeometry, capsuleMaterial);
        this.cameraCapsule.position.y = 0.8;
        this.camera = new THREE.PerspectiveCamera(25, window.innerWidth / window.innerHeight, 0.1, 1000);
        this.camera.updateMatrixWorld();
        this.renderer = new THREE.WebGLRenderer({ antialias: true, depth: true, powerPreference: 'high-performance' });
        this.renderer.xr.enabled = true;
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.shadowMap.enabled = true;
        this.renderer.autoClear = false;
        this.renderer.shadowMap.type = THREE.PCFShadowMap;
        this.renderer.shadowMap.needsUpdate = true;
        this.renderer.toneMapping = THREE.ReinhardToneMapping;
        this.renderer.toneMappingExposure = 1.2;
        this.renderer.outputEncoding = THREE.sRGBEncoding;
        this.renderer.setAnimationLoop(this.animate.bind(this));

        document.body?.appendChild(this.renderer.domElement);
        const sessionInit = {
            optionalFeatures: ['dom-overlay'],
            requiredFeatures: ['hand-tracking']
        };
        document.body?.appendChild(VRButton.createButton(this.renderer, sessionInit));

        window.addEventListener('resize', () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
        });

        this.stats = new Stats();
        this.stats.dom.style.width = '80px';
        this.stats.dom.style.height = '48px';
        document.body?.appendChild(this.stats.dom);

        this.performance = new PerformanceHelper();
        document.body?.appendChild(this.performance.dom);

        this.announcement = new Announcement();
        this.announcement.textContent(`Gunakan joystick pada controller untuk scroll\n @author Wildy13`);
        document.body?.appendChild(this.announcement.dom);

        this.cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256, {
            type: THREE.HalfFloatType,
            format: THREE.RGBAFormat,
            encoding: THREE.sRGBEncoding,
        });
        this.cubeRenderTarget.texture.encoding = THREE.sRGBEncoding;
        this.cubeCamera = new THREE.CubeCamera(0.1, 20, this.cubeRenderTarget);
        this.cubeCamera.position.y = 1.6;

        this.scene.environment = this.cubeRenderTarget.texture;

        this.ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
        this.scene.add(this.ambientLight);

        this.directionalLight = new THREE.DirectionalLight(0xffffff, 10);
        this.directionalLight.position.set(-30, 5, 0);
        this.directionalLight.shadow.mapSize.width = 2048;
        this.directionalLight.shadow.mapSize.height = 2048;
        this.directionalLight.shadow.camera.near = 0.1;
        this.directionalLight.shadow.camera.far = 200;
        this.directionalLight.shadow.camera.left = -200;
        this.directionalLight.shadow.camera.right = 200;
        this.directionalLight.shadow.camera.top = 200;
        this.directionalLight.shadow.camera.bottom = -200;
        this.directionalLight.shadow.bias = -0.0005;
        this.scene.add(this.directionalLight);

        //const helper = new THREE.DirectionalLightHelper(this.directionalLight, 5);
        //this.scene.add(helper);

        this.spotLight = new THREE.SpotLight(0xffffff, 10, 100, Math.PI / 4, 0.5, 2);
        this.spotLight.position.set(-100, 10, 0);
        this.spotLight.target.position.set(0, 0, 0)
        this.spotLight.castShadow = true;
        this.spotLight.shadow.mapSize.width = 4096;
        this.spotLight.shadow.mapSize.height = 4096;
        this.spotLight.shadow.camera.near = 500;
        this.spotLight.shadow.camera.far = 500;
        this.spotLight.shadow.camera.fov = 30;
        this.spotLight.penumbra = .2;
        this.scene.add(this.spotLight);

        //const spotLightHelper = new THREE.SpotLightHelper(this.spotLight);
        //this.scene.add(spotLightHelper);

        this.rightController = this.renderer.xr.getController(0);
        this.leftController = this.renderer.xr.getController(1);
        this.leftController.userData.handedness = 'left';
        this.rightController.userData.handedness = 'right';

        const controllerModelFactory = new XRControllerModelFactory();

        this.rightControllerGrip = this.renderer.xr.getControllerGrip(0);
        this.leftControllerGrip = this.renderer.xr.getControllerGrip(1);

        this.leftControllerGrip.add(controllerModelFactory.createControllerModel(this.leftControllerGrip));
        this.rightControllerGrip.add(controllerModelFactory.createControllerModel(this.rightControllerGrip));

        const leftMaterial = new THREE.ShaderMaterial({
            uniforms: {
                lineColor: { value: new THREE.Color(0xffffff) }
            },
            vertexShader:
                `
            varying float vDistance;
            void main() {
                vec4 worldPosition = modelViewMatrix * vec4(position, 1.0);
                vDistance = length(worldPosition.xyz);
                gl_Position = projectionMatrix * worldPosition;
            }
            `
            ,
            fragmentShader:
                `
            uniform vec3 lineColor;
            varying float vDistance;
            void main() {
                float opacity = 1.0 - smoothstep(0.5, 1.0, vDistance);
                gl_FragColor = vec4(lineColor, opacity);
            }
            `,
            transparent: true
        });

        const rightMaterial = new THREE.ShaderMaterial({
            uniforms: {
                lineColor: { value: new THREE.Color(0xffffff) }
            },
            vertexShader:
                `
            varying float vDistance;
            void main() {
                vec4 worldPosition = modelViewMatrix * vec4(position, 1.0);
                vDistance = length(worldPosition.xyz);
                gl_Position = projectionMatrix * worldPosition;
            }
            `
            ,
            fragmentShader:
                `
            uniform vec3 lineColor;
            varying float vDistance;
            void main() {
                float opacity = 1.0 - smoothstep(0.5, 1.0, vDistance);
                gl_FragColor = vec4(lineColor, opacity);
            }
            `,
            transparent: true
        });

        const leftGeometry = new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -3)
        ]);
        const rightGeometry = new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -3)
        ]);

        const leftLine = new THREE.Line(leftGeometry, leftMaterial);
        leftLine.name = 'line left';
        leftLine.frustumCulled = false;

        const rightLine = new THREE.Line(rightGeometry, rightMaterial);
        rightLine.name = 'line right';
        rightLine.frustumCulled = false;

        this.leftController.add(leftLine);
        this.rightController.add(rightLine);
        this.cameraCapsule.add(this.leftController);
        this.cameraCapsule.add(this.rightController);
        this.cameraCapsule.add(this.leftControllerGrip);
        this.cameraCapsule.add(this.rightControllerGrip);
        this.cameraCapsule.add(this.camera);

        this.scene.add(this.cameraCapsule);

    }

    createPanel() {
        const width = 1024;
        const height = 2048

        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');

        /// initial scroll offset
        this.scrollOffset = 0;

        // draw some demo content
        const drawCanvas = () => {
            ctx.fillStyle = '#333';
            ctx.fillRect(0, 0, width, height);
            ctx.fillStyle = '#fff';
            ctx.font = '40px sans-serif';
            for (let i = 0; i < 50; i++) {
                ctx.fillText(`Item ${i + 1}`, 50, 50 + i * 60);
            }
        };
        drawCanvas();

        // create texture
        this.scrollCanvas = canvas;
        this.scrollTexture = new THREE.CanvasTexture(canvas);
        this.scrollTexture.encoding = THREE.sRGBEncoding;
        this.scrollTexture.needsUpdate = true;

        // show only portion of canvas as visible area (e.g., 1024x512)
        const visibleHeight = 512;
        const visibleCanvas = document.createElement('canvas');
        visibleCanvas.width = width;
        visibleCanvas.height = visibleHeight;
        const visibleCtx = visibleCanvas.getContext('2d');

        this.visibleCanvas = visibleCanvas;
        this.visibleCtx = visibleCtx;
        this.visibleTexture = new THREE.CanvasTexture(visibleCanvas);
        this.visibleTexture.encoding = THREE.sRGBEncoding;
        this.visibleTexture.minFilter = THREE.LinearFilter;

        const updateVisibleCanvas = () => {
            this.visibleCtx.clearRect(0, 0, width, visibleHeight);
            this.visibleCtx.drawImage(
                this.scrollCanvas,
                0, this.scrollOffset,
                width, visibleHeight,
                0, 0,
                width, visibleHeight
            );
            this.visibleTexture.needsUpdate = true;
        };
        this.updateVisibleCanvas = updateVisibleCanvas;

        // mesh
        const material = new THREE.MeshBasicMaterial({ map: this.visibleTexture, side: THREE.DoubleSide });
        const geometry = new THREE.PlaneGeometry(2, 1); // size in world units
        this.scrollPlane = new THREE.Mesh(geometry, material);
        this.scrollPlane.position.set(0, 2, -2);
        this.scene.add(this.scrollPlane);

        // auto update
        setInterval(updateVisibleCanvas, 1000 / 30); // ~30fps update

        // scroll method
        this.scrollContent = (deltaY) => {
            this.scrollOffset = Math.max(0, Math.min(this.scrollCanvas.height - visibleHeight, this.scrollOffset + deltaY));
        };
    }

    addController() {
        if (this.manager) return;

        this.isScrollingUp = false;
        this.isScrollingDown = false;
        this.manager = new Register();
        const entity = this.manager.createEntity();

        entity.addComponent(Object3D, { object: this.scrollPlane });
        entity.addComponent(PanelComponent, { scrollContent: this.scrollContent });
        entity.addComponent(ControllerComponent, { renderer: this.renderer, controllers: [this.leftController, this.rightController] })
    }

    animate() {
        this.stats.update();
        this.performance.update(this.renderer);
        this.cubeCamera.update(this.renderer, this.scene);

        this.spotLight.target.position.set(
            this.cameraCapsule.position.x + this.cameraCapsule.getWorldDirection(new THREE.Vector3()).x * 10,
            this.cameraCapsule.position.y + this.cameraCapsule.getWorldDirection(new THREE.Vector3()).y * 10,
            this.cameraCapsule.position.z + this.cameraCapsule.getWorldDirection(new THREE.Vector3()).z * 10
        )

        if (this.manager) this.manager.update(this.clock.getDelta(), this.clock.elapsedTime);

        this.renderer.render(this.scene, this.camera);
    }
}

class ControllerComponent extends Component { }

ControllerComponent.schema = {
    renderer: { type: Types.Ref, default: null },
    controllers: { type: Types.Ref, default: null },
}

class ControllerSystem extends System {
    init() {
        this.controllers = null;
        this.objectsToTest = null;
        this.previousButtonStates = {
            left: [],
            right: []
        };
        this.prevColor = {
            left: null,
            right: null
        };
    }

    execute() {
        this.queries.controllers.results.forEach(entity => {
            const components = entity.getComponent(ControllerComponent);
            const renderer = components.renderer;
            const controllers = components.controllers;
            const session = renderer.xr.getSession();

            if (session) {
                session.inputSources.forEach(source => {
                    this.handleController(source, controllers, entity);
                });
            }
        });
    }

    handleController(source, controllers, entity) {
        const object = entity.getComponent(Object3D)?.object;
        if (object) {
            controllers.forEach((c, i) => {
                if (source.handedness === c.userData.handedness) {
                    const intersections = this.getIntersections(c, object);
                    if (intersections.length > 0) {
                        const intersection = intersections[0];
                        this.updateControllerLine(c, intersection);

                        source.gamepad.buttons.forEach((b, i) => {
                            this.handleButtonsPress(b, i, c, intersection, entity);
                            this.handleJoystick(source, intersection, entity);
                        });
                    }
                }
            });
        }
    }

    handleJoystick(source, intersection, entity) {
        if (!source || !intersection || !entity) return;

        const handedness = source.handedness;
        const gamepad = source.gamepad;
        const intersectionObject = intersection.object;
        if (!intersectionObject) return;

        if (entity.hasComponent(PanelComponent)) {
            const panel = entity.getMutableComponent(PanelComponent);
            if (gamepad) {
                const axes = gamepad.axes;
                if (axes.length >= 2) {
                    const vertical = axes[3]; // thumbstick Y-axis

                    if (vertical > 0.3) {
                        panel.scrollContent(vertical);
                    } else if (vertical < -0.3) {
                        panel.scrollContent(vertical);
                    }
                }
            }
        }
    }

    getIntersections(controller, object) {
        const tempMatrix = new THREE.Matrix4();
        const raycaster = new THREE.Raycaster();
        tempMatrix.identity().extractRotation(controller.matrixWorld);
        raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
        raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
        controller.updateMatrixWorld();
        return raycaster.intersectObject(object, false);
    }

    updateControllerLine(controller, intersection) {
        const line = controller.getObjectByName(`line ${controller.userData.handedness}`);

        if (line) {
            const scale = line.scale.z;
            line.scale.z = intersection.distance || scale;
        }
    }

    handleButtonsPress(button, index, controller, intersection, entity) {
        if (!this.previousButtonStates[controller.userData.handedness]) {
            this.previousButtonStates[controller.userData.handedness] = [];
        }
        const wasPressed = this.previousButtonStates[controller.userData.handedness][index];

        if (button.pressed && !wasPressed) {
            this.ChangeLineController(controller, 0x22d3ee);
            this.processButtonAction(index, controller, entity, intersection);
        } else if (!button.pressed && wasPressed) {
            this.ChangeLineController(controller, 0xffffff);
        }

        this.previousButtonStates[controller.userData.handedness][index] = button.pressed;
    }

    ChangeLineController(controller, color) {
        const line = controller.getObjectByName(`line ${controller.userData.handedness}`);
        const hand = controller.userData.handedness;

        const newColor = new THREE.Color(color);

        if (line && line.material && (!this.prevColor[hand] || !newColor.equals(this.prevColor[hand]))) {
            line.material.uniforms.lineColor.value.set(newColor);
            line.material.needsUpdate = true;

            this.prevColor[hand] = newColor.clone();
        }
    }

    processButtonAction(index, controller, entity, intersection) {
        switch (index) {
            case 0:
                console.log(`button ${index} has been clicked in ${controller.userData.handedness} controller`);
                this.action(controller, entity, intersection);
                break;
            case 1:
                console.log(`button ${index} has been clicked in ${controller.userData.handedness} controller`);
                this.action(controller, entity, intersection);
                break;
            case 3:
                console.log(`button ${index} has been clicked in ${controller.userData.handedness} controller`);
                this.action(controller, entity, intersection);
                break;
        }
    }

    action(controller, entity, intersection) {
        if (!intersection) return;

        const intersectionObject = intersection.object;
        if (!intersectionObject) return;

        if (entity.hasComponent(PanelComponent)) {
            const panel = entity.getMutableComponent(PanelComponent);
            console.log(intersectionObject)
        }
    }
}

ControllerSystem.queries = {
    controllers: {
        components: [ControllerComponent]
    }
};

class PanelComponent extends Component { }
PanelComponent.schema = {
    state: { type: Types.String, default: 'none' },
    scrollContent: { type: Types.Ref }
};

class PanelSystem extends System {
    execute() {
        this.queries.panels.results.forEach(entity => {
            const components = entity.getMutableComponent(PanelComponent);
            const object = entity.getComponent(Object3D).object;

            switch (components.state) {
                case 'scrollUp':
                    break;

                case 'scrollDown':
                    break;

                default:
                    break;
            }
        });
    }
}

PanelSystem.queries = {
    panels: {
        components: [PanelComponent]
    }
};

class Object3D extends Component { }
Object3D.schema = {
    object: { type: Types.Ref },
};

class Register {
    constructor() {
        this.world = new World();

        if (!this.world.hasRegisteredComponent(PanelComponent)) this.world.registerComponent(PanelComponent);
        if (!this.world.getSystem(PanelSystem)) this.world.registerSystem(PanelSystem);
        if (!this.world.hasRegisteredComponent(Object3D)) this.world.registerComponent(Object3D);
        if (!this.world.hasRegisteredComponent(ControllerComponent)) this.world.registerComponent(ControllerComponent);
        if (!this.world.getSystem(ControllerSystem)) this.world.registerSystem(ControllerSystem);
    }

    createEntity() {
        return this.world.createEntity();
    }

    update(delta, time) {
        this.world.execute(delta, time);
    }
}

const app = new App();
app.createPanel();
app.addController();

document.addEventListener('DOMContentLoaded', () => {
    try {
        document.getElementById('VRButton').click();
    } catch (error) {
        console.error('there is error while DOMContentLoaded', error);
    }
})

```

i tried for clip using this code before 🙂

```js
   const minHeight = this.scrollPlane.position.y + this.scrollPlane.geometry.parameters.height / 2;
    const maxHeight = minHeight - (this.contentGroup.children.length * 0.4); // Adjust based on item height and number of items

    // Create clipping plane
    const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), minHeight);

```

but fail idk why, can you help me?

---

<div class="post-metadata">

**Author:** ![manthrax](https://yyz1.discourse-cdn.com/flex035/user_avatar/discourse.threejs.org/manthrax/32/2596_2.png) [@manthrax](https://discourse.threejs.org/u/manthrax)\
**Post date:** [April 17, 2025, 1:44am UTC](https://discourse.threejs.org/t/how-to-create-a-scrollable-plane-with-clipping-in-three-js/81171/2 "2025-04-17T01:44:35Z")

</div>

The clipping planes only works with the built in materials.

Your shaderMaterials would have to be modified to account for the clipping planes.

A possibly simpler solution is to render all your panel stuff into a separate rendertarget of some fixed width/height, and use that as the planes material.map.

Another approach would be to use the depth buffer or stencil buffer to mask out your plane area, but that gets pretty complex.

---

<div class="post-metadata">

**Author:** ![wildy13](https://yyz1.discourse-cdn.com/flex035/user_avatar/discourse.threejs.org/wildy13/32/53564_2.png) [@wildy13](https://discourse.threejs.org/u/wildy13)\
**Post date:** [April 17, 2025, 2:14am UTC](https://discourse.threejs.org/t/how-to-create-a-scrollable-plane-with-clipping-in-three-js/81171/4 "2025-04-17T02:14:07Z")

</div>

> [@manthrax](#):
>
> The clipping planes only works with the built in materials.
> 
> Your shaderMaterials would have to be modified to account for the clipping planes.
> 
> A possibly simpler solution is to render all your panel stuff into a separate rendertarget of some fixed width/height, and use that as the planes material.map.
> 
> Another approach would be to use the depth buffer or stencil buffer to mask out your plane area, but that gets pretty complex.

ahh i see, so clipping planes only work to standardMaterial, etc not ctx .

so if i using normal plane from planeGeometry and have planes as its children, its better using shaderMaterial because we can using clip on it ? sorry if im wrong
