Coder's Block

Physics-Based Background Scroll Effects

This article will show you how to create nifty physics-based background scroll effects for your web pages. We’ll be using Matter.js, an excellent open-source JavaScript framework, to handle the physics for us.

The Game Plan

Let’s get right to it. Here’s the first demo. Scroll it and watch the background.

See the Pen Floaty Bubbles by Will Boyd (@lonekorean) on CodePen.

Notice the bubbles don’t have a fixed path as you scroll. They float around, bump into each other, and carry momentum. By simulating physics for the bubbles, we’ve created a background that feels lively and organic.

How do we accomplish this? Here’s the high-level overview.

  1. Use Matter.js to simulate a bunch of circlular bodies (the bubbles).
  2. Render these bodies onto a <canvas> that is fixed behind the page content and sized to match the width and height of the viewport.
  3. Add a scroll event listener on the window to detect scrolling and apply appropriate velocity to the bodies.

Feel free to check out the code that makes this all happen. If you want the guided tour, read on and I’ll cover some of the highlights.

Disclaimer

Don’t forget about usability! Make sure your page content is readable and be cognizant of those with motion sensitivity. The prefers-reduced-motion media query is relevant here, though sadly only supported by Safari at the time of writing.

These demos were made to show off the concept, but you should temper them in practice. Consider the tone and purpose of your site and use your best judgement.

Digging Into the Code

We start with a <canvas> element in the HTML for Matter.js to use.

<canvas id="bg"></canvas>

A little bit of CSS positions the <canvas> behind the main page content.

canvas {
    position: fixed;
    z-index: -1;
    top: 0;
    left: 0;
}

We also need to set the width and height of the <canvas> to cover the full viewport. This is actually done later in JavaScript, but getting the dimensions to use is easy enough.

let viewportWidth = document.documentElement.clientWidth;
let viewportHeight = document.documentElement.clientHeight;

The code is organized as a prototype object with several configurable options, an init() function to kick things off, and then a bunch of other properties and methods to handle various things.

let floatyBubbles = {
    // customizable options (passed into init function)
    options: {
        canvasSelector: '',             // to find <canvas> in DOM to draw on
        radiusRange: [50, 100],         // random range of body radii
        xVarianceRange: [-0.5, 0.5],    // random range of x velocity scaling on bodies
        yVarianceRange: [0.5, 1.5],     // random range of y velocity scaling on bodies
        airFriction: 0.03,              // air friction of bodies
        opacity: 1,                     // opacity of bodies
        collisions: true,               // do bodies collide or pass through
        scrollVelocity: 0.025,          // scaling of scroll delta to velocity applied to bodies
        pixelsPerBody: 50000,           // viewport pixels required for each body added

        // colors to cycle through to fill bodies
        colors: ['#e4e4cc', '#e1d2c4', '#d1e4df']
    },

    // more properties...

    init(options) {
        // override default options with incoming options
        this.options = Object.assign({}, this.options, options);

        // more code to start things up...
    },

    // more methods...
};

Having all the customizable options lumped together makes it really easy to tweak values and experiment (go ahead, try it out). We can also easily override them when calling init() to kick things off.

// wait for DOM to load
window.addEventListener('DOMContentLoaded', () => {
    // start floaty bubbles background
    Object.create(floatyBubbles).init({
        canvasSelector: '#bg'
    });
});

The init() function also creates and configures 3 important objects for using Matter.js: the engine (does the work of simulating physics), the render (sets up the <canvas> and draws on it), and the runner (keeps the engine running).

// engine
this.engine = Matter.Engine.create();
this.engine.world.gravity.y = 0;

// render
this.render = Matter.Render.create({
    canvas: document.querySelector(this.options.canvasSelector),
    engine: this.engine,
    options: {
        width: viewportWidth,
        height: viewportHeight,
        wireframes: false,
        background: 'transparent'
    }
});
Matter.Render.run(this.render);

// runner
this.runner = Matter.Runner.create();
Matter.Runner.run(this.runner, this.engine);

After that we can calculate how many bodies (bubbles) will fit in the viewport, then create them in a loop.

this.bodies = [];
let totalBodies = Math.round(viewportWidth * viewportHeight / this.options.pixelsPerBody);
for (let i = 0; i <= totalBodies; i++) {
    let body = this.createBody(viewportWidth, viewportHeight);
    this.bodies.push(body);
}
Matter.World.add(this.engine.world, this.bodies);

The createBody() method looks like this.

createBody(viewportWidth, viewportHeight) {
    let x = this.randomize([0, viewportWidth]);
    let y = this.randomize([0, viewportHeight]);
    let radius = this.randomize(this.options.radiusRange);
    let color = this.options.colors[this.bodies.length % this.options.colors.length];

    return Matter.Bodies.circle(x, y, radius, {
        render: {
            fillStyle: color,
            opacity: this.options.opacity
        },
        frictionAir: this.options.airFriction,
        collisionFilter: {
            group: this.options.collisions ? 1 : -1
        },
        plugin: {
            wrap: {
                min: { x: 0, y: 0 },
                max: { x: viewportWidth, y: viewportHeight }
            }
        }
    });
}

It picks a random position and radius for the circle body, then calls Matter.Bodies.circle() to create it. A lot of the customizable options come into play here.

One of the things we pass into Matter.Bodies.circle() is a plugin.wrap object. This is for the matter-wrap plugin, which we’re using to make bodies wrap around to the other side of the <canvas> when they move beyond an edge. This lets us recycle bodies as they move in/out of view, instead of constantly creating new ones while scrolling.

Speaking of scrolling, here’s the line of code to add the scroll event listener.

window.addEventListener('scroll', this.onScrollThrottled.bind(this));

Scrolling can fire events very rapidly, so we use onScrollThrottled() to create a timeout that limits how often we call onScroll(), which is where the real work is done.

onScrollThrottled() {
    if (!this.scrollTimeout) {
        this.scrollTimeout = setTimeout(this.onScroll.bind(this), this.scrollDelay);
    }
}

onScroll() determines how far the page has been scrolled, then adds an appropriate amount of vertical velocity to each body. It injects a little randomness, and even a bit of horizontal velocity, to make the bubbles more lively.

onScroll() {
    this.scrollTimeout = null;

    let delta = (this.lastOffset - window.pageYOffset) * this.options.scrollVelocity;
    this.bodies.forEach((body) => {
        Matter.Body.setVelocity(body, {
            x: body.velocity.x + delta * this.randomize(this.options.xVarianceRange),
            y: body.velocity.y + delta * this.randomize(this.options.yVarianceRange)
        });
    });

    this.lastOffset = window.pageYOffset;
}

That concludes the guided tour. I left some minor things out, but again, the full source code is all yours if you’re interested. The nice thing about this code is that it’s very reusable. With some minor tweaks and a bit of CSS, we can easily create other physics-based background scroll effects.

Read on and I’ll show you.

Remix

The next demo shows off a hex bokeh effect. Give it a scroll and watch the background.

See the Pen Hex Bokeh by Will Boyd (@lonekorean) on CodePen.

As mentioned, we’re recycling just about all the code from the first demo, but with a few adjustments. The most obvious is that we’ve replaced circles with hexagons.

return Matter.Bodies.polygon(x, y, 6, radius, {
    // same code from before...
};

We’ve also changed a couple options. Notably, xVarianceRange has positive range values to create the slight diagonal scroll effect, opacity is reduced to make bodies semi-transparent, and collisions are disabled so bodies float over each other.

options: {
    canvasSelector: '',             // to find <canvas> in DOM to draw on
    radiusRange: [30, 60],          // random range of body radii
    xVarianceRange: [0.1, 0.3],     // random range of x velocity scaling on bodies
    yVarianceRange: [0.5, 1.5],     // random range of y velocity scaling on bodies
    airFriction: 0.03,              // air friction of bodies
    opacity: 0.5,                   // opacity of bodies
    collisions: false,              // do bodies collide or pass through
    scrollVelocity: 0.025,          // scaling of scroll delta to velocity applied to bodies
    pixelsPerBody: 40000,           // viewport pixels required for each body added

    // colors to cycle through to fill bodies
    colors: ['#7ef2cf', '#bea6ff', '#8ccdff']
}

Here’s the twist. We’re actually using 2 <canvas> backgrounds together to create a depth of field effect.

<canvas id="bg1"></canvas>
<canvas id="bg2"></canvas>

The top <canvas> has a 1px blur just to soften the edges, while the <canvas> behind it has a 10px blur to appear out of focus. This is done with a CSS filter.

#bg1 {
    z-index: -1;
    filter: blur(1px);
}

#bg2 {
    z-index: -2;
    filter: blur(10px);
}

When it comes to kicking things off in JavaScript, the first background leaves the options as shown above. The second background overrides radiusRange and pixelsPerBody, creating larger bodies but less of them.

// wait for DOM to load
window.addEventListener('DOMContentLoaded', () => {
    // start first hex bokeh background
    Object.create(hexBokeh).init({
        canvasSelector: '#bg1'
    });

    // start second hex bokeh background
    Object.create(hexBokeh).init({
        canvasSelector: '#bg2',
        radiusRange: [100, 200],
        pixelsPerBody: 70000
    });
});

And there you have it. We’ve created a markedly different effect without changing the core of how the code works.

Let’s do it again!

Now with SVG Filters

This final demo uses an SVG filter to achieve the famous gooey blob effect. Once again, give the demo a scroll and watch how the blobs move.

See the Pen Goo Blobs by Will Boyd (@lonekorean) on CodePen.

This demo goes back to simulating fully opaque circular bodies, except without collisions. airFriction is increased and scrollVelocity is decreased to give the blobs a thicker feel as you scroll. xVarianceRange is bigger to make the blobs more chaotic.

options: {
    canvasSelector: '',             // to find <canvas> in DOM to draw on
    radiusRange: [75, 150],         // random range of body radii
    xVarianceRange: [-1, 1],        // random range of x velocity scaling on bodies
    yVarianceRange: [0.5, 1.5],     // random range of y velocity scaling on bodies
    airFriction: 0.04,              // air friction of bodies
    opacity: 1,                     // opacity of bodies
    collisions: false,              // do bodies collide or pass through
    scrollVelocity: 0.015,          // scaling of scroll delta to velocity applied to bodies
    pixelsPerBody: 50000,           // viewport pixels required for each body added

    // colors to cycle through to fill bodies
    colors: ['#bad4cf']
}

Here’s the SVG filter to create the gooey blob effect. If you want an explanation of how it works, here’s an excellent article.

<svg>
    <defs>
        <filter id="goo">
            <feGaussianBlur in="SourceGraphic" stdDeviation="30" result="blur" />
            <feColorMatrix in="blur" mode="matrix" values="
                1 0 0 0 0
                0 1 0 0 0
                0 0 1 0 0
                0 0 0 50 -16
            " result="matrix" />
            <feBlend in="SourceGraphic" in2="matrix" />
        </filter>
    </defs>
</svg>

Once again, we’re using double <canvas> backgrounds. They both apply the goo filter, but the second background has a slight blur for depth of field.

#bg1 {
    z-index: -1;
    filter: url('#goo');
}

#bg2 {
    z-index: -2;
    filter: url('#goo') blur(4px);
}

In the JavaScript, the first background goes with the options shown above. The second background overrides yVarianceRange with negative values, causing the blobs to move in the opposite direction of scrolling. We also override pixelsPerBody to add more mass to the blobs and colors to make these blobs lighter.

// wait for DOM to load
window.addEventListener('DOMContentLoaded', () => {
    // start first goo blobs background
    Object.create(gooBlobs).init({
        canvasSelector: '#bg1'
    });

    // start second goo blobs background
    Object.create(gooBlobs).init({
        canvasSelector: '#bg2',
        yVarianceRange: [-1.5, -0.5],
        pixelsPerBody: 30000,
        colors: ['#d3f0ea']
    });
});

Keep Playing

Hopefully this article has shown you something interesting to play with. Again, all the code is yours to take and is structured to make it easy to tweak. You can even play around with the demos on CodePen (floaty bubbles, hex bokeh, goo blobs).

Thanks for reading!

Will Boyd