An Interactive Starry Backdrop for Content

I was fortunate last year to get approached by Shawn Wang (swyx) about doing some work for Temporal. The idea was to cast my creative eye over what was on the site and come up with some ideas that would give the site a little “something” extra. This was quite a neat challenge as I consider myself more of a developer than a designer. But I love learning and leveling up the design side of my game.

One of the ideas I came up with was this interactive starry backdrop. You can see it working in this shared demo:

The neat thing about this design is that it’s built as a drop-in React component. And it’s super configurable in the sense that once you’ve put together the foundations for it, you can make it completely your own. Don’t want stars? Put something else in place. Don’t want randomly positioned particles? Place them in a constructed way. You have total control of what to bend it to your will.

So, let’s look at how we can create this drop-in component for your site! Today’s weapons of choice? React, GreenSock and HTML <canvas>. The React part is totally optional, of course, but, having this interactive backdrop as a drop-in component makes it something you can employ on other projects.

Let’s start by scaffolding a basic app

import React from 'https://cdn.skypack.dev/react'
import ReactDOM from 'https://cdn.skypack.dev/react-dom'
import gsap from 'https://cdn.skypack.dev/gsap' const ROOT_NODE = document.querySelector('#app') const Starscape = () => <h1>Cool Thingzzz!</h1> const App = () => <Starscape/> ReactDOM.render(<App/>, ROOT_NODE)

First thing we need to do is render a <canvas> element and grab a reference to it that we can use within React’s useEffect. For those not using React, store a reference to the <canvas> in a variable instead.

const Starscape = () => { const canvasRef = React.useRef(null) return <canvas ref={canvasRef} />
}

Our <canvas> is going to need some styles, too. For starters, we can make it so the canvas takes up the full viewport size and sits behind the content:

canvas { position: fixed; inset: 0; background: #262626; z-index: -1; height: 100vh; width: 100vw;
}

Cool! But not much to see yet.

We need stars in our sky

We’re going to “cheat” a little here. We aren’t going to draw the “classic” pointy star shape. We’re going to use circles of differing opacities and sizes.

Draw a circle on a <canvas> is a case of grabbing a context from the <canvas> and using the arc function. Let’s render a circle, err star, in the middle. We can do this within a React useEffect:

const Starscape = () => { const canvasRef = React.useRef(null) const contextRef = React.useRef(null) React.useEffect(() => { canvasRef.current.width = window.innerWidth canvasRef.current.height = window.innerHeight contextRef.current = canvasRef.current.getContext('2d') contextRef.current.fillStyle = 'yellow' contextRef.current.beginPath() contextRef.current.arc( window.innerWidth / 2, // X window.innerHeight / 2, // Y 100, // Radius 0, // Start Angle (Radians) Math.PI * 2 // End Angle (Radians) ) contextRef.current.fill() }, []) return <canvas ref={canvasRef} />
}

So what we have is a big yellow circle:

This is a good start! The rest of our code will take place within this useEffect function. That’s why the React part is kinda optional. You can extract this code out and use it in whichever form you like.

We need to think about how we’re going to generate a bunch of “stars” and render them. Let’s create a LOAD function. This function is going to handle generating our stars as well as the general <canvas> setup. We can also move the sizing logic of the <canvas> sizing logic into this function:

const LOAD = () => { const VMIN = Math.min(window.innerHeight, window.innerWidth) const STAR_COUNT = Math.floor(VMIN * densityRatio) canvasRef.current.width = window.innerWidth canvasRef.current.height = window.innerHeight starsRef.current = new Array(STAR_COUNT).fill().map(() => ({ x: gsap.utils.random(0, window.innerWidth, 1), y: gsap.utils.random(0, window.innerHeight, 1), size: gsap.utils.random(1, sizeLimit, 1), scale: 1, alpha: gsap.utils.random(0.1, defaultAlpha, 0.1), }))
}

Our stars are now an array of objects. And each star has properties that define their characteristics, including:

  • x: The star’s position on the x-axis
  • y: The star’s position on the y-axis
  • size: The star’s size, in pixels
  • scale: The star’s scale, which will come into play when we interact with the component
  • alpha: The star’s alpha value, or opacity, which will also come into play during interactions

We can use GreenSock’s random() method to generate some of these values. You may also be wondering where sizeLimit, defaultAlpha, and densityRatio came from. These are now props we can pass to the Starscape component. We’ve provided some default values for them:

const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5 }) => {

A randomly generated star Object might look like this:

{ "x": 1252, "y": 29, "size": 4, "scale": 1, "alpha": 0.5
}

But, we need to see these stars and we do that by rendering them. Let’s create a RENDER function. This function will loop over our stars and render each of them onto the <canvas> using the arc function:

const RENDER = () => { contextRef.current.clearRect( 0, 0, canvasRef.current.width, canvasRef.current.height ) starsRef.current.forEach(star => { contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})` contextRef.current.beginPath() contextRef.current.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2) contextRef.current.fill() })
}

Now, we don’t need that clearRect function for our current implementation as we are only rendering once onto a blank <canvas>. But clearing the <canvas> before rendering anything isn’t a bad habit to get into, And it’s one we’ll need as we make our canvas interactive.

Consider this demo that shows the effect of not clearing between frames.

Our Starscape component is starting to take shape.

See the code
const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5 }) => { const canvasRef = React.useRef(null) const contextRef = React.useRef(null) const starsRef = React.useRef(null) React.useEffect(() => { contextRef.current = canvasRef.current.getContext('2d') const LOAD = () => { const VMIN = Math.min(window.innerHeight, window.innerWidth) const STAR_COUNT = Math.floor(VMIN * densityRatio) canvasRef.current.width = window.innerWidth canvasRef.current.height = window.innerHeight starsRef.current = new Array(STAR_COUNT).fill().map(() => ({ x: gsap.utils.random(0, window.innerWidth, 1), y: gsap.utils.random(0, window.innerHeight, 1), size: gsap.utils.random(1, sizeLimit, 1), scale: 1, alpha: gsap.utils.random(0.1, defaultAlpha, 0.1), })) } const RENDER = () => { contextRef.current.clearRect( 0, 0, canvasRef.current.width, canvasRef.current.height ) starsRef.current.forEach(star => { contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})` contextRef.current.beginPath() contextRef.current.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2) contextRef.current.fill() }) } LOAD() RENDER() }, []) return <canvas ref={canvasRef} />
}

Have a play around with the props in this demo to see how they affect the the way stars are rendered.

Before we go further, you may have noticed a quirk in the demo where resizing the viewport distorts the <canvas>. As a quick win, we can rerun our LOAD and RENDER functions on resize. In most cases, we’ll want to debounce this, too. We can add the following code into our useEffect call. Note how we also remove the event listener in the teardown.

// Naming things is hard...
const RUN = () => { LOAD() RENDER()
} RUN() // Set up event handling
window.addEventListener('resize', RUN)
return () => { window.removeEventListener('resize', RUN)
}

Cool. Now when we resize the viewport, we get a new generated starry.

Interacting with the starry backdrop

Now for the fun part! Let’s make this thing interactive.

The idea is that as we move our pointer around the screen, we detect the proximity of the stars to the mouse cursor. Depending on that proximity, the stars both brighten and scale up.

We’re going to need to add another event listener to pull this off. Let’s call this UPDATE. This will work out the distance between the pointer and each star, then tween each star’s scale and alpha values. To make sure those tweeted values are correct, we can use GreenSock’s mapRange() utility. In fact, inside our LOAD function, we can create references to some mapping functions as well as a size unit then share these between the functions if we need to.

Here’s our new LOAD function. Note the new props for scaleLimit and proximityRatio. They are used to limit the range of how big or small a star can get, plus the proximity at which to base that on.

const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5, scaleLimit = 2, proximityRatio = 0.1
}) => { const canvasRef = React.useRef(null) const contextRef = React.useRef(null) const starsRef = React.useRef(null) const vminRef = React.useRef(null) const scaleMapperRef = React.useRef(null) const alphaMapperRef = React.useRef(null) React.useEffect(() => { contextRef.current = canvasRef.current.getContext('2d') const LOAD = () => { vminRef.current = Math.min(window.innerHeight, window.innerWidth) const STAR_COUNT = Math.floor(vminRef.current * densityRatio) scaleMapperRef.current = gsap.utils.mapRange( 0, vminRef.current * proximityRatio, scaleLimit, 1 ); alphaMapperRef.current = gsap.utils.mapRange( 0, vminRef.current * proximityRatio, 1, defaultAlpha ); canvasRef.current.width = window.innerWidth canvasRef.current.height = window.innerHeight starsRef.current = new Array(STAR_COUNT).fill().map(() => ({ x: gsap.utils.random(0, window.innerWidth, 1), y: gsap.utils.random(0, window.innerHeight, 1), size: gsap.utils.random(1, sizeLimit, 1), scale: 1, alpha: gsap.utils.random(0.1, defaultAlpha, 0.1), })) }
}

And here’s our UPDATE function. It calculates the distance and generates an appropriate scale and alpha for a star:

const UPDATE = ({ x, y }) => { starsRef.current.forEach(STAR => { const DISTANCE = Math.sqrt(Math.pow(STAR.x - x, 2) + Math.pow(STAR.y - y, 2)); gsap.to(STAR, { scale: scaleMapperRef.current( Math.min(DISTANCE, vminRef.current * proximityRatio) ), alpha: alphaMapperRef.current( Math.min(DISTANCE, vminRef.current * proximityRatio) ) }); })
};

But wait… it doesn’t do anything?

Well, it does. But, we haven’t set our component up to show updates. We need to render new frames as we interact. We can reach for requestAnimationFrame often. But, because we’re using GreenSock, we can make use of gsap.ticker. This is often referred to as “the heartbeat of the GSAP engine” and it’s is a good substitute for requestAnimationFrame.

To use it, we add the RENDER function to the ticker and make sure we remove it in the teardown. One of the neat things about using the ticker is that we can dictate the number of frames per second (fps). I like to go with a “cinematic” 24fps:

// Remove RUN
LOAD()
gsap.ticker.add(RENDER)
gsap.ticker.fps(24) window.addEventListener('resize', LOAD)
document.addEventListener('pointermove', UPDATE)
return () => { window.removeEventListener('resize', LOAD) document.removeEventListener('pointermove', UPDATE) gsap.ticker.remove(RENDER)
}

Note how we’re now also running LOAD on resize. We also need to make sure our scale is being picked up in that RENDER function when using arc:

const RENDER = () => { contextRef.current.clearRect( 0, 0, canvasRef.current.width, canvasRef.current.height ) starsRef.current.forEach(star => { contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})` contextRef.current.beginPath() contextRef.current.arc( star.x, star.y, (star.size / 2) * star.scale, 0, Math.PI * 2 ) contextRef.current.fill() })
}

It works! 🙌

It’s a very subtle effect. But, that’s intentional because, while it’s is super neat, we don’t want this sort of thing to distract from the actual content. I’d recommend playing with the props for the component to see different effects. It makes sense to set all the stars to low alpha by default too.

The following demo allows you to play with the different props. I’ve gone for some pretty standout defaults here for the sake of demonstration! But remember, this article is more about showing you the techniques so you can go off and make your own cool backdrops — while being mindful of how it interacts with content.

Refinements

There is one issue with our interactive starry backdrop. If the mouse cursor leaves the <canvas>, the stars stay bright and upscaled but we want them to return to their original state. To fix this, we can add an extra handler for pointerleave. When the pointer leaves, this tweens all of the stars down to scale 1 and the original alpha value set by defaultAlpha.

const EXIT = () => { gsap.to(starsRef.current, { scale: 1, alpha: defaultAlpha, })
} // Set up event handling
window.addEventListener('resize', LOAD)
document.addEventListener('pointermove', UPDATE)
document.addEventListener('pointerleave', EXIT)
return () => { window.removeEventListener('resize', LOAD) document.removeEventListener('pointermove', UPDATE) document.removeEventListener('pointerleave', EXIT) gsap.ticker.remove(RENDER)
}

Neat! Now our stars scale back down and return to their previous alpha when the mouse cursor leaves the scene.

Bonus: Adding an Easter egg

Before we wrap up, let’s add a little Easter egg surprise to our interactive starry backdrop. Ever heard of the Konami Code? It’s a famous cheat code and a cool way to add an Easter egg to our component.

We can practically do anything with the backdrop once the code runs. Like, we could make all the stars pulse in a random way for example. Or they could come to life with additional colors? It’s an opportunity to get creative with things!

We’re going listen for keyboard events and detect whether the code gets entered. Let’s start by creating a variable for the code:

const KONAMI_CODE = 'arrowup,arrowup,arrowdown,arrowdown,arrowleft,arrowright,arrowleft,arrowright,keyb,keya';

Then we create a second effect within our the starry backdrop. This is a good way to maintain a separation of concerns in that one effect handles all the rendering, and the other handles the Easter egg. Specifically, we’re listening for keyup events and check whether our input matches the code.

const codeRef = React.useRef([])
React.useEffect(() => { const handleCode = e => { codeRef.current = [...codeRef.current, e.code] .slice( codeRef.current.length > 9 ? codeRef.current.length - 9 : 0 ) if (codeRef.current.join(',').toLowerCase() === KONAMI_CODE) { // Party in here!!! } } window.addEventListener('keyup', handleCode) return () => { window.removeEventListener('keyup', handleCode) }
}, [])

We store the user input in an Array that we store inside a ref. Once we hit the party code, we can clear the Array and do whatever we want. For example, we may create a gsap.timeline that does something to our stars for a given amount of time. If this is the case, we don’t want to allow Konami code to input while the timeline is active. Instead, we can store the timeline in a ref and make another check before running the party code.

const partyRef = React.useRef(null)
const isPartying = () => partyRef.current && partyRef.current.progress() !== 0 && partyRef.current.progress() !== 1;

For this example, I’ve created a little timeline that colors each star and moves it to a new position. This requires updating our LOAD and RENDER functions.

First, we need each star to now have its own hue, saturation and lightness:

// Generating stars! ⭐️
starsRef.current = new Array(STAR_COUNT).fill().map(() => ({ hue: 0, saturation: 0, lightness: 100, x: gsap.utils.random(0, window.innerWidth, 1), y: gsap.utils.random(0, window.innerHeight, 1), size: gsap.utils.random(1, sizeLimit, 1), scale: 1, alpha: defaultAlpha
}));

Second, we need to take those new values into consideration when rendering takes place:

starsRef.current.forEach((star) => { contextRef.current.fillStyle = `hsla( ${star.hue}, ${star.saturation}%, ${star.lightness}%, ${star.alpha} )`; contextRef.current.beginPath(); contextRef.current.arc( star.x, star.y, (star.size / 2) * star.scale, 0, Math.PI * 2 ); contextRef.current.fill();
});

And here’s the fun bit of code that moves all the stars around:

partyRef.current = gsap.timeline().to(starsRef.current, { scale: 1, alpha: defaultAlpha
}); const STAGGER = 0.01; for (let s = 0; s < starsRef.current.length; s++) { partyRef.current .to( starsRef.current[s], { onStart: () => { gsap.set(starsRef.current[s], { hue: gsap.utils.random(0, 360), saturation: 80, lightness: 60, alpha: 1, }) }, onComplete: () => { gsap.set(starsRef.current[s], { saturation: 0, lightness: 100, alpha: defaultAlpha, }) }, x: gsap.utils.random(0, window.innerWidth), y: gsap.utils.random(0, window.innerHeight), duration: 0.3 }, s * STAGGER );
}

From there, we generate a new timeline and tween the values of each star. These new values get picked up by RENDER. We’re adding a stagger by positioning each tween in the timeline using GSAP’s position parameter.

That’s it!

That’s one way to make an interactive starry backdrop for your site. We combined GSAP and an HTML <canvas>, and even sprinkled in some React that makes it more configurable and reusable. We even dropped an Easter egg in there!

Where can you take this component from here? How might you use it on a site? The combination of GreenSock and <canvas> is a lot of fun and I’m looking forward to seeing what you make! Here are a couple more ideas to get your creative juices flowing…