Skip to example

Three.js scroll-driven logo

An example of a scroll-driven Three.js animation where the Motion logo wireframe draws in and fills with a reflective material, using scroll(), motion values and threeEffect in Motion.

JavaScript

Source code

<section class="track">
    <div class="viewport">
        <canvas></canvas>
        <p class="hint">Scroll to draw</p>
    </div>
</section>

<script type="module">
    import { frame, motionValue, scroll, transformValue } from "motion"
    import { threeEffect } from "motion/three"
    import * as THREE from "three"
    import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"

    const token = (name) =>
        getComputedStyle(document.body).getPropertyValue(name).trim()
    const mint = token("--hue-6")

    const canvas = document.querySelector("canvas")

    function runScrollMotion(logo, wireMaterial, fillMaterial, wireLength) {
        /**
         * One scroll progress value fans out to the wireframe, the fill and the
         * rotation through transformValue, each bound with threeEffect.
         */
        const progress = motionValue(0)
        const clamp = (value) => Math.min(1, Math.max(0, value))
        const drawn = transformValue(() => clamp(progress.get() / 0.6))
        const filled = transformValue(() => clamp((progress.get() - 0.5) / 0.4))

        threeEffect(wireMaterial, {
            dashSize: transformValue(() => wireLength * drawn.get()),
            opacity: transformValue(() => 1 - filled.get() * 0.9),
        })
        threeEffect(fillMaterial, { opacity: filled })
        threeEffect(logo, {
            rotateY: transformValue(() => -50 + progress.get() * 70),
            rotateX: transformValue(() => 25 - progress.get() * 25),
        })

        scroll((value) => progress.set(value), {
            target: document.querySelector(".track"),
        })
    }

    const renderer = new THREE.WebGLRenderer({
        canvas,
        antialias: true,
        alpha: true,
    })
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
    renderer.setSize(canvas.clientWidth, canvas.clientHeight, false)
    renderer.toneMapping = THREE.ACESFilmicToneMapping

    const scene = new THREE.Scene()
    const camera = new THREE.PerspectiveCamera(
        32,
        canvas.clientWidth / canvas.clientHeight,
        0.1,
        100
    )
    camera.position.set(0, 0.6, 7.5)
    camera.lookAt(0, 0, 0)

    const pmrem = new THREE.PMREMGenerator(renderer)
    scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture

    /**
     * The Motion logo as extruded shapes. Coordinates are the SVG path with
     * y flipped, then centred and scaled to world units.
     */
    function logoShapes() {
        const left = new THREE.Shape()
        left.moveTo(9.587, 9)
        left.lineTo(4.57, 0)
        left.lineTo(0, 0)
        left.lineTo(3.917, 7.028)
        left.bezierCurveTo(4.524, 8.117, 6.039, 9, 7.301, 9)

        const middle = new THREE.Shape()
        middle.moveTo(10.443, 9)
        middle.lineTo(15.013, 9)
        middle.lineTo(9.997, 0)
        middle.lineTo(5.427, 0)

        const right = new THREE.Shape()
        right.moveTo(15.841, 9)
        right.lineTo(20.411, 9)
        right.lineTo(16.494, 1.972)
        right.bezierCurveTo(15.887, 0.883, 14.372, 0, 13.11, 0)
        right.lineTo(10.825, 0)

        const dot = new THREE.Shape()
        dot.absarc(23.079, 6.75, 2.285, 0, Math.PI * 2)

        return [left, middle, right, dot]
    }

    const geometry = new THREE.ExtrudeGeometry(logoShapes(), {
        depth: 1.6,
        bevelEnabled: false,
        curveSegments: 12,
    })
    geometry.center()
    geometry.scale(4 / 25.364, 4 / 25.364, 4 / 25.364)

    const logo = new THREE.Group()
    scene.add(logo)

    /**
     * The wireframe is a dashed line with one dash and a gap longer than
     * every edge combined. Growing the dash from 0 to the total edge length
     * makes the outline draw itself in.
     */
    const wireMaterial = new THREE.LineDashedMaterial({
        color: mint,
        transparent: true,
        dashSize: 0,
    })
    const wire = new THREE.LineSegments(
        new THREE.EdgesGeometry(geometry, 20),
        wireMaterial
    )
    wire.computeLineDistances()
    const distances = wire.geometry.attributes.lineDistance.array
    const wireLength = distances[distances.length - 1]
    wireMaterial.gapSize = wireLength * 10
    logo.add(wire)

    const fillMaterial = new THREE.MeshPhysicalMaterial({
        color: mint,
        metalness: 1,
        roughness: 0.15,
        transparent: true,
        opacity: 0,
    })
    const fill = new THREE.Mesh(geometry, fillMaterial)
    logo.add(fill)

    frame.render(() => renderer.render(scene, camera), true)
    runScrollMotion(logo, wireMaterial, fillMaterial, wireLength)
</script>

<style>
    .track {
        width: 100vw;
        height: 400vh;
    }

    .viewport {
        position: sticky;
        top: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 16px;
        height: 100vh;
    }

    .viewport canvas {
        display: block;
        width: 480px;
        max-width: 100vw;
        aspect-ratio: 3 / 2;
    }

    .hint {
        margin: 0;
        font-family: var(--font-mono);
        font-size: 11px;
        letter-spacing: 0.1em;
        text-transform: uppercase;
        color: var(--white-feint);
    }
</style>