From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
In this tutorial we’ll explore various animated WebGL text typing effects. We will mostly be using Three.js but not the whole tutorial relies on the specific features of this library.
But who doesn’t love Three.js though ❤️
This tutorial is aimed at developers who are familiar with the basic concepts of WebGL.
The main idea is to create a JavaScript template that takes a keyboard input and draws the text on the screen in some fancy way. The effects we will build today are all about composing a text shape with a big number of repeating objects. We will cover the following steps:
- Sampling text on Canvas (generating 2D coordinates)
- Setting up the scene and placing the Canvas element
- Generating particles in 3D space
- Turning particles to an instanced mesh
- Replacing a static string with some user input
- Basic animation
- Typing-related animation
- Generating the visuals: clouds, bubbles, flowers, eyeballs
Text sampling
In the following we will fill a text shape with some particles.
First, let’s think about what a 3D text shape is. In general, a text mesh is nothing but a 2D shape being extruded. So we don’t need to sample the 3rd coordinate – we can just use X/Y
coordinates with Z
being randomly generated within the text depth (although we’re not about to use the Z
coordinate much today).
One of the ways to generate 2D coordinates inside the shape is with Canvas sampling. So let’s create a <canvas>
element, apply some font-related styles to it and make sure the size of <canvas>
is big enough for the text to fit (extra space is okay).
// Settings
const fontName = 'Verdana';
const textureFontSize = 100;
// String to show
let string = 'Some text' + '\n' + 'to sample' + '\n' + 'with Canvas';
// Create canvas to sample the text
const textCanvas = document.createElement('canvas');
const textCtx = textCanvas.getContext('2d');
document.body.appendChild(textCanvas);
// ---------------------------------------------------------------
sampleCoordinates();
// ---------------------------------------------------------------
function sampleCoordinates() {
// Parse text
const lines = string.split(`\n`);
const linesMaxLength = [...lines].sort((a, b) => b.length - a.length)[0].length;
const wTexture = textureFontSize * .7 * linesMaxLength;
const hTexture = lines.length * textureFontSize;
// ...
}
With the Canvas API you can set all the font styling pretty much like in CSS. Custom fonts can be used as well, but I’m using good old Verdana today.
Once the style is set, we draw the text (or any other graphics!) on the <canvas>…
function sampleCoordinates() {
// Parse text
// ...
// Draw text
const linesNumber = lines.length;
textCanvas.width = wTexture;
textCanvas.height = hTexture;
textCtx.font = '100 ' + textureFontSize + 'px ' + fontName;
textCtx.fillStyle = '#2a9d8f';
textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height);
for (let i = 0; i < linesNumber; i++) {
textCtx.fillText(lines[i], 0, (i + .8) * hTexture / linesNumber);
}
// ...
}
… to be able to get imageData from it.
The ImageData
object contains a one-dimensional array with RGBA data for every pixel. Knowing the size of the canvas
, we can go through the array and check if the given X/Y
coordinate matches the color of text or the color of the background.
Since our canvas
doesn’t have anything but colored text on the unset (transparent black) background, we can check any of the four RGBA bytes with against a condition as simple as “bigger than zero”.
function sampleCoordinates() {
// Parse text
// ...
// Draw text
// ...
// Sample coordinates
textureCoordinates = [];
const samplingStep = 4;
if (wTexture > 0) {
const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height);
for (let i = 0; i < textCanvas.height; i += samplingStep) {
for (let j = 0; j < textCanvas.width; j += samplingStep) {
// Checking if R-channel is not zero since the background RGBA is (0,0,0,0)
if (imageData.data[(j + i * textCanvas.width) * 4] > 0) {
textureCoordinates.push({
x: j,
y: i
})
}
}
}
}
}
There’re lots of things you can do with the sampling function: change the sampling step, add some randomness, apply an outline stroke to the text, and more. Below we’ll keep using only the simplest sampling. To check the result we can add a second <canvas>
and draw the dot for each of sampled textureCoordinates
.
The Three.js scene
Let’s set up a basic Three.js scene and place a Plane
object on it. We can use the text sampling Canvas from the previous step as a color map for the Plane
.
Generating the particles
We can generate 3D coordinates with the very same sampling function. X/Y
are gathered from the Canvas and for the Z
coordinate we can take a random number.
The easiest way to visualize this set of coordinates would be a particle system known as THREE.Points
.
function createParticles() {
const geometry = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
color: 0xff0000,
size: 2
});
const vertices = [];
for (let i = 0; i < textureCoordinates.length; i ++) {
vertices.push(textureCoordinates[i].x, textureCoordinates[i].y, 5 * Math.random());
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
const particles = new THREE.Points(geometry, material);
scene.add(particles);
}
Somehow it works ¯\_(ツ)_/¯
Obviously, we need to flip the Y
coordinate for each particle and center the whole text.
To do both, we need to know the bounding box of our text. There are various ways to measure the box using the canvas API or Three.js functions. But as a temporary solution, we just take max X and Y coordinates as width and height of the text.
function refreshText() {
sampleCoordinates();
// Gather with and height of the bounding box
const maxX = textureCoordinates.map(v => v.x).sort((a, b) => (b - a))[0];
const maxY = textureCoordinates.map(v => v.y).sort((a, b) => (b - a))[0];
stringBox.wScene = maxX;
stringBox.hScene = maxY;
createParticles();
}
For each point, the Y
coordinate becomes boxTotalHeight - Y
.
Shifting the whole particles system by half-width and half-height of the box solves the centering issue.
function createParticles() {
// ...
for (let i = 0; i < textureCoordinates.length; i ++) {
// Turning Y coordinate to stringBox.hScene - Y
vertices.push(textureCoordinates[i].x, stringBox.hScene - textureCoordinates[i].y, 5 * Math.random());
}
// ...
// Centralizing the text
particles.position.x = -.5 * stringBox.wScene;
particles.position.y = -.5 * stringBox.hScene;
}
Until now, we were using pixel coordinates gathered from text canvas
directly on the 3D scene. But let’s say we need the 3D text to have the height equal to 10 units. If we set 10 as a font size, the canvas
resolution would be too low to make a proper sampling. To avoid it (and to be more flexible with the particles density), we can add an additional scaling factor: the value we’d multiply the canvas coordinates with before using them in 3D space.
// Settings
// ...
const textureFontSize = 30;
const fontScaleFactor = .3;
// ...
function refreshText() {
// ...
textureCoordinates = textureCoordinates.map(c => {
return { x: c.x * fontScaleFactor, y: c.y * fontScaleFactor }
});
// ...
}
At this point, we can also remove the Plane
object. We keep using the canvas
to draw the text and sample coordinates but we don’t need to turn it to a texture and put it on the scene.
Switching to instanced mesh
Of course there are many cool things we can do with THREE.Points
but our next step is turning the particles into THREE.InstancedMesh
.
The main limitation of THREE.Points
is the particle size. THREE.PointsMaterial
is based on WebGL gl_PointSize
, which can be rendered with a maximum pixel size of around 50 to 100, depending on your video card. So even if we need our particles to be as simple as planes, we sometimes can’t use THREE.Points
due to this limitation. You may think about THREE.Sprite
as an alternative, but (surprisingly) instanced mesh gives us much better performance on the big (10k+) number of particles.
Plus, if we want to use 3D shapes as particles, THREE.InstancedMesh
is the only choice.
There is a well-known approach to work with THREE.InstancedMesh
:
- Create an instanced mesh with a known number of instances. In our case, the number of instances is the length of our coordinates array.
function createInstancedMesh() {
instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, textureCoordinates.length);
scene.add(instancedMesh);
// centralize it in the same way as before
instancedMesh.position.x = -.5 * stringBox.wScene;
instancedMesh.position.y = -.5 * stringBox.hScene;
}
- Add the geometry and material to be used for each instance. I use a doughnut shape known as
THREE.TorusGeometry
andTHREE.MeshNormalMaterial
.
function init() {
// Create scene and text canvas
// ...
// Instanced geometry and material
particleGeometry = new THREE.TorusGeometry(.1, .05, 16, 50);
particleMaterial = new THREE.MeshNormalMaterial({ });
// ...
}
- Create a dummy object that helps us generate a 4×4 transform matrix for each particle. It doesn’t need to be a part of the scene.
function init() {
// Create scene, text canvas, instanced geometry and material
// ...
dummy = new THREE.Object3D();
}
- Apply the transform matrix to each instance with the
.setMatrixAt
method
function updateParticlesMatrices() {
let idx = 0;
textureCoordinates.forEach(p => {
// we apply samples coordinates like before + some random rotation
dummy.rotation.set(2 * Math.random(), 2 * Math.random(), 2 * Math.random());
dummy.position.set(p.x, stringBox.hScene - p.y, Math.random());
dummy.updateMatrix();
instancedMesh.setMatrixAt(idx, dummy.matrix);
idx ++;
})
instancedMesh.instanceMatrix.needsUpdate = true;
}
Listening to the keyboard
So far, the string
value was hard-coded. We want it to be dynamic and contain the user input.
There are many ways to listen to the keyboard: working directly with keyup/keydown events, using the HTML input element as a proxy, etc. I ended up with a <div>
element that has a contenteditable attribute set. Compared to an <input>
or a <textarea>
, it’s more painful to parse the multi-line string from an editable <div>
. But it’s much easier to get an accurate pixel values for the cursor position and the text bounding box.
I won’t go too much into details here. The main idea is to keep the editable <div>
focused all the time so that we keep track of whatever the user types there.
<div id="text-input" contenteditable="true" onblur="this.focus()" autofocus></div>
Using the keyup event
we parse the string
and get the width and height of stringBox
from the contenteditable <div>
, and then refresh the instanced mesh.
document.addEventListener('keyup', () => {
handleInput();
refreshText();
});
While parsing, we replace the inner tags with new lines (this part is specific for <div contenteditable>
), and do a few things for usability like disabling empty new lines above and below the text.
Please note that <div contenteditable>
and text canvas
should have the same CSS properties (font, font size, etc). With the same styles applied, the text is rendered in the very same way on both elements. With that in place, we can take the pixel values from <div contenteditable>
(text width, height, cursor position) and use them for the canvas
.
const textInputEl = document.querySelector('#text-input');
textInputEl.style.fontSize = textureFontSize + 'px';
textInputEl.style.font = '100 ' + textureFontSize + 'px ' + fontName;
textInputEl.style.lineHeight = 1.1 * textureFontSize + 'px';
// ...
function handleInput() {
if (isNewLine(textInputEl.firstChild)) {
textInputEl.firstChild.remove();
}
if (isNewLine(textInputEl.lastChild)) {
if (isNewLine(textInputEl.lastChild.previousSibling)) {
textInputEl.lastChild.remove();
}
}
string = textInputEl.innerHTML
.replaceAll("", "\n")
.replaceAll("", "")
.replaceAll("", "\n")
.replaceAll("", "")
.replaceAll("", "")
.replaceAll("", "")
.replaceAll(" ", " ");
stringBox.wTexture = textInputEl.clientWidth;
stringBox.wScene = stringBox.wTexture * fontScaleFactor;
stringBox.hTexture = textInputEl.clientHeight;
stringBox.hScene = stringBox.hTexture * fontScaleFactor;
function isNewLine(el) {
if (el) {
if (el.tagName) {
if (el.tagName.toUpperCase() === 'DIV' || el.tagName.toUpperCase() === 'P') {
if (el.innerHTML === '' || el.innerHTML === '') {
return true;
}
}
}
}
return false
}
}
Once we have the string
and the stringBox
, we update the instanced mesh.
function refreshText() {
sampleCoordinates();
textureCoordinates = textureCoordinates.map(c => {
return { x: c.x * fontScaleFactor, y: c.y * fontScaleFactor }
});
// This part can be removed as we take text size from editable
Coordinate sampling is the same as before with one difference: we now can create canvas
with the exact text size, no extra space to sample.
function sampleCoordinates() {
const lines = string.split(`\n`);
// This part can be removed as we take text size from editable
We can’t increase the number of instances for the existing mesh. So the mesh should be recreated every time the text is updated. Although text centering and instances transform is done exactly like before.
// function createInstancedMesh() {
function recreateInstancedMesh() {
// Now we need to remove the old Mesh and create a new one every refreshText() call
scene.remove(instancedMesh);
instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, textureCoordinates.length);
// ...
}
function updateParticlesMatrices() {
// same as before
//...
}
Since our text is dynamic and it can get pretty long, let’s make sure the instanced mesh fits the screen:
function refreshText() {
// ...
makeTextFitScreen();
}
function makeTextFitScreen() {
const fov = camera.fov * (Math.PI / 180);
const fovH = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect);
const dx = Math.abs(.55 * stringBox.wScene / Math.tan(.5 * fovH));
const dy = Math.abs(.55 * stringBox.hScene / Math.tan(.5 * fov));
const factor = Math.max(dx, dy) / camera.position.length();
if (factor > 1) {
camera.position.x *= factor;
camera.position.y *= factor;
camera.position.z *= factor;
}
}
One more thing to add is a caret (text cursor). It can be a simple 3D box with a size matching the font size.
function init() {
// ...
const cursorGeometry = new THREE.BoxGeometry(.3, 4.5, .03);
cursorGeometry.translate(.5, -2.7, 0)
const cursorMaterial = new THREE.MeshNormalMaterial({
transparent: true,
});
cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial);
scene.add(cursorMesh);
}
We gather the position of the caret from our editable <div>
in pixels and multiply it by fontScaleFactor
, like we do with the bounding box width and height.
function handleInput() {
// ...
stringBox.caretPosScene = getCaretCoordinates().map(c => c * fontScaleFactor);
function getCaretCoordinates() {
const range = window.getSelection().getRangeAt(0);
const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0);
if (needsToWorkAroundNewlineBug) {
return [
range.startContainer.offsetLeft,
range.startContainer.offsetTop
]
} else {
const rects = range.getClientRects();
if (rects[0]) {
return [rects[0].left, rects[0].top]
} else {
// since getClientRects() gets buggy in FF
document.execCommand('selectAll', false, null);
return [
0, 0
]
}
}
}
}
The cursor just needs same centering as our instanced mesh
has, and voilà, the 3D caret position is the same as in the the input div.
function refreshText() {
// ...
updateCursorPosition();
}
function updateCursorPosition() {
cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0];
cursorMesh.position.y = .5 * stringBox.hScene - stringBox.caretPosScene[1];
}
The only thing left is to make the cursor blink when the page (and hence the input element) is focused. The roundPulse
function generates the rounded pulse between 0 and 1 from THREE.Clock.getElapsedTime()
. We need to update the cursor opacity all the time, so the updateCursorOpacity
call goes to the main render
loop.
function render() {
// ...
updateCursorOpacity();
// ...
}
let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2);
function updateCursorOpacity() {
if (document.hasFocus() && document.activeElement === textInputEl) {
cursorMesh.material.opacity = roundPulse(2 * clock.getElapsedTime());
} else {
cursorMesh.material.opacity = 0;
}
}
Basic animation
Instead of setting the instances transform just on the text update, we can also animate this transform.
To do this, we add an additional array of Particle
objects to store the parameters for each instance. We still need the textureCoordinates
array to store the 2D coordinates in pixels, but now we remap them to the particles
array. And obviously, the particles transform update should happen in the main render
loop now.
// ...
let textureCoordinates = [];
let particles = [];
function refreshText() {
// ...
// textureCoordinates are only pixel coordinates, particles is array of data objects
particles = textureCoordinates.map(c =>
new Particle([c.x * fontScaleFactor, c.y * fontScaleFactor])
);
// We call it in the render() loop now
// updateParticlesMatrices();
// ...
}
Each Particle
object contains a list of properties and a grow()
function that updates some of those properties.
For starters, we define position, rotation and scale. Position would be static for each particle, scale would increase from zero to one when the particle is created, and rotation would be animated all the time.
function Particle([x, y]) {
this.x = x;
this.y = y;
this.z = 0;
this.rotationX = Math.random() * 2 * Math.PI;
this.rotationY = Math.random() * 2 * Math.PI;
this.rotationZ = Math.random() * 2 * Math.PI;
this.scale = 0;
this.deltaRotation = .2 * (Math.random() - .5);
this.deltaScale = .01 + .2 * Math.random();
this.grow = function () {
this.rotationX += this.deltaRotation;
this.rotationY += this.deltaRotation;
this.rotationZ += this.deltaRotation;
if (this.scale < 1) {
this.scale += this.deltaScale;
}
}
}
// ...
function updateParticlesMatrices() {
let idx = 0;
// textureCoordinates.forEach(p => {
particles.forEach(p => {
// update the particles data
p.grow();
// dummy.rotation.set(2 * Math.random(), 2 * Math.random(), 2 * Math.random());
dummy.rotation.set(p.rotationX, p.rotationY, p.rotationZ);
dummy.scale.set(p.scale, p.scale, p.scale);
dummy.position.set(p.x, stringBox.hScene - p.y, p.z);
dummy.updateMatrix();
instancedMesh.setMatrixAt(idx, dummy.matrix);
idx ++;
})
instancedMesh.instanceMatrix.needsUpdate = true;
}
Typing animation
We already have a nice template by now. But every time the text is updated we recreate all the instances for all the symbols. So every time the text is changed we reset all the properties and animations of all the particles.
Instead, we need to keep the properties and animations for “old” particles. To do so, we need to know if each particle should be recreated or not.
In other words, for each sampled coordinate we need to check if Particle
already exists or not. If we found a Particle
object with the same X/Y
coordinates, we keep it along with all its properties. If there is no existing Particle
for the sampled coordinate, we call new Particle()
like we did before.
We evolve the sampling function so we don’t only gather the X/Y values and refill textureCoordinates
array but also do the following:
- Turn one-dimensional array
imageData
to two-dimensionalimageMask
array - Go through the existing
textureCoordinates
array and compare its elements to theimageMask
. If coordinate exists, addold
property to the coordinate, otherwise addtoDelete
property. - All the sampled coordinates that were not found in the
textureCoordinates
, we handle as new coordinate that has X and Y values andold
ortoDelete
properties set tofalse
It would make sense to simply delete old coordinates that were not found in the new imageMask
. But we use a special toDelete
property instead to play a fade-out animation for deleted particles first, and actually delete the Particle data only in the next step.
function sampleCoordinates() {
// Draw text
// ...
// Sample coordinates
if (stringBox.wTexture > 0) {
// Image data to 2d array
const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height);
const imageMask = Array.from(Array(textCanvas.height), () => new Array(textCanvas.width));
for (let i = 0; i < textCanvas.height; i++) {
for (let j = 0; j < textCanvas.width; j++) {
imageMask[i][j] = imageData.data[(j + i * textCanvas.width) * 4] > 0;
}
}
if (textureCoordinates.length !== 0) {
// Clean up: delete coordinates and particles which disappeared on the prev step
// We need to keep same indexes for coordinates and particles to reuse old particles properly
textureCoordinates = textureCoordinates.filter(c => !c.toDelete);
particles = particles.filter(c => !c.toDelete);
// Go through existing coordinates (old to keep, toDelete for fade-out animation)
textureCoordinates.forEach(c => {
if (imageMask[c.y]) {
if (imageMask[c.y][c.x]) {
c.old = true;
if (!c.toDelete) {
imageMask[c.y][c.x] = false;
}
} else {
c.toDelete = true;
}
} else {
c.toDelete = true;
}
});
}
// Add new coordinates
for (let i = 0; i < textCanvas.height; i++) {
for (let j = 0; j < textCanvas.width; j++) {
if (imageMask[i][j]) {
textureCoordinates.push({
x: j,
y: i,
old: false,
toDelete: false
})
}
}
}
} else {
textureCoordinates = [];
}
}
With old
and toDelete
properties, mapping texture coordinates to the particles becomes conditional:
function refreshText() {
// ...
// particles = textureCoordinates.map(c =>
// new Particle([c.x * fontScaleFactor, c.y * fontScaleFactor])
// );
particles = textureCoordinates.map((c, cIdx) => {
const x = c.x * fontScaleFactor;
const y = c.y * fontScaleFactor;
let p = (c.old && particles[cIdx]) ? particles[cIdx] : new Particle([x, y]);
if (c.toDelete) {
p.toDelete = true;
p.scale = 1;
}
return p;
});
// ...
}
The grow()
call would not only increase the size of the particle when it’s created. We would also decrease it if the particle meant to be deleted.
function Particle([x, y]) {
// ...
this.toDelete = false;
this.grow = function () {
// ...
if (this.scale < 1) {
this.scale += this.deltaScale;
}
if (this.toDelete) {
this.scale -= this.deltaScale;
if (this.scale <= 0) {
this.scale = 0;
}
}
}
}
The template is now ready and we can use it to create various effects with only little changes.
Bubbles effect 🫧
See the Pen Bubble Typer Three.js – Demo #2 by Ksenia Kondrashova (@ksenia-k) on CodePen.
Here is the full list of changes I made to make these bubbles based on the template:
- Change
TorusGeometry
toIcosahedronGeometry
so each instance is a sphere - Replace
MeshNormalMaterial
withShaderMaterial
. You can check out the GLSL code in the sandbox above but the shader essentially does this:- mix white color and randomized gradient (taken from normal vector), and use the result as sphere color
- applies transparency in a way to make less transparent outline and more transparent middle of the sphere if you look from the camera position
- Adjust
textureFontSize
andfontScaleFactor
values to change the density of the particles - Evolve the
Particle
object so that- bubble position is a bit randomized comparing to the sampled coordinates
- maximum size of the bubble is defined by randomized
maxScale
property - no rotation
- bubbles size is randomized as the scale limit is
maxScale
property, not 1 - bubble grows all the time, bursts, and then grows again. So the scale increase happens not only when
Particle
is created but all the time. Once the scale reaches themaxScale
value, we reset the scale to zero - some bubbles would get
isFlying
property so they move up from the initial position
- Change color of page background and cursor
Clouds effect ☁️
You don’t need to do much for having clouds, too:
- Use
PlaneGeometry
for instance shape - Use
MeshBasicMaterial
and apply the following image as an alpha map - Adjust
textureFontSize
andfontScaleFactor
to change the density of the particles - Evolve the
Particle
object so that- particle position is a bit randomized compared to the sampled coordinates
- size of the particle is defined by randomized
maxScale
property - only rotation around Z axis is needed
- particle size (scale) is pulsating all the time
- Additional transform
.copy(camera.quaternion)
should be applied for each instance. This way the particle is always facing towards the camera; rotate the cloudy text to see the result 🙂 - Change color of page background and cursor
See the Pen Clouds Typer Three.js – Demo #1 by Ksenia Kondrashova (@ksenia-k) on CodePen.
Flowers effect 🌸
Flowers are actually quite similar to clouds. The main difference is about having two instanced meshes and two materials. One is mapped as flower texture, another one as a leaf
Also, all the particles must have a new color
property. We apply colors to the instanced mesh
with the setColorAt
method every time we recreate the meshes.
With a few small changes like particles density, scaling speed, rotation speed, and the color of the background and cursor, we have this:
See the Pen Flower Typer Three.js – Demo #3 by Ksenia Kondrashova (@ksenia-k) on CodePen.
Eyes effect 👀
We can go further and load a glb
model and use it as an instance! I took this nice looking eye from turbosquid.com
Instead of applying a random rotation, we can make the eyeballs follow the mouse position! To do so, we need an additional transparent plane in front of the instanced mesh, THREE.Raycaster()
and the mouse position tracker. We are listening to the mousemove
event, set ray from mouse to the plane, and make the dummy
object look at the intersection point.
Don’t forget to add some lights to see the imported model. And as we have lights, let’s make the instanced mesh cast the shadow to the plane behind the text.
Together with some other small changes like sampling density, grow()
function parameters, cursor and background style, we get this:
See the Pen Eyes Typer Three.js – Demo #4 by Ksenia Kondrashova (@ksenia-k) on CodePen.
And that’s it! I hope this tutorial was interesting and that it gave you some inspiration. Feel free to use this template to create more fun things!