Lightning Animation

Creating custom animations in paper.js and typescript

Introduction

Captivating animations can make a website stand out and provide a memorable user experience. One feature that makes my personal blog truly unique is the scroll interaction based animation on the landing page. A particularly interesting section within this animation are the procedurally generated black lightnings rendered on a white background. In addition to the randomized stroke width, a blurry shadow is used to create a bloom-like effect, enhancing the overall visual appeal.This mesmerizing effect is implemented using a powerful 2d vector graphics framework, paper.js, in conjunction with TypeScript. Paper.js is a vector graphics library for the browser that offers a clean scene graph and document object model and a broad range of features. It's built on top of the HTML5 Canvas API, providing an elegant and simple way to work with graphical applications in the browser without getting into the nitty-gritty of canvas commands.

Understanding the lightning class

First, let's have a look at my implementation of the lightning class.

typescript
1
class Lightning {
2
public path: paper.Path;
3
public speed = 0.025;
4
5
private startPoint: paper.Point;
6
private endPoint: paper.Point;
7
private step: number;
8
private amplitude = 1;
9
private off = 0;
10
private _simplexNoise = createNoise2D();
11
12
// child stuff
13
private parent: Lightning | null = null;
14
private children: Lightning[];
15
private childCount: number;
16
private startStep = 0;
17
private endStep = 0;
18
19
constructor({
20
startPoint,
21
endPoint,
22
childCount,
23
}: {
24
startPoint?: paper.Point;
25
endPoint?: paper.Point;
26
childCount?: number;
27
} = {}) {
28
this.startPoint = startPoint ?? new Point(0, 0);
29
this.endPoint = endPoint ?? new Point(0, 0);
30
this.childCount = childCount ?? 0;
31
this.step = 45;
32
this.path = new Path({
33
strokeColor: "black",
34
strokeWidth: 3,
35
shadowBlur: 5,
36
shadowColor: "black",
37
segments: [],
38
});
39
40
this.children = [];
41
for (let i = 0; i < this.childCount; i++) {
42
const child = new Lightning();
43
child.setAsChild(this);
44
this.children.push(child);
45
}
46
}
47
48
public setAsChild(lightning: Lightning) {
49
if (!(lightning instanceof Lightning)) return;
50
this.parent = lightning;
51
52
const setTimer = () => {
53
this.updateStepsByParent();
54
setTimeout(setTimer, randomInt(1500));
55
};
56
57
setTimeout(setTimer, randomInt(1500));
58
}
59
60
private updateStepsByParent() {
61
if (!this.parent) return;
62
var parentStep = this.parent.step;
63
this.startStep = randomInt(parentStep - 2);
64
this.endStep =
65
this.startStep + randomInt(parentStep - this.startStep - 2) + 2;
66
this.step = this.endStep - this.startStep;
67
}
68
69
public length() {
70
return this.endPoint.subtract(this.startPoint).length;
71
}
72
73
public setOpacity(o: number) {
74
this.path.opacity = o;
75
this.children.forEach((c) => (c.path.opacity = o));
76
}
77
78
public update() {
79
const startPoint = this.startPoint;
80
const endPoint = this.endPoint;
81
82
if (this.parent) {
83
if (this.endStep > this.parent.step) {
84
this.updateStepsByParent();
85
}
86
87
startPoint.set(this.parent.path.segments[this.startStep].point);
88
endPoint.set(this.parent.path.segments[this.endStep].point);
89
}
90
91
const length = this.length();
92
const normal = endPoint
93
.subtract(startPoint)
94
.normalize()
95
.multiply(length / this.step);
96
const radian = toRadians(normal.angle);
97
const sinv = Math.sin(radian);
98
const cosv = Math.cos(radian);
99
100
const off = (this.off += random(this.speed, this.speed * 0.2));
101
let waveWidth = (this.parent ? length * 1.5 : length) * this.amplitude;
102
if (waveWidth > 750) waveWidth = 750;
103
104
this.path.segments = [];
105
106
for (let i = 0, len = this.step + 1; i < len; i++) {
107
const n = i / 60;
108
const av = waveWidth * this.noise(n - off) * 0.5;
109
const ax = sinv * av;
110
const ay = cosv * av;
111
112
const bv = waveWidth * this.noise(n + off) * 0.5;
113
const bx = sinv * bv;
114
const by = cosv * bv;
115
116
const m = Math.sin(Math.PI * (i / (len - 1)));
117
118
const x = startPoint.x + normal.x * i + (ax - bx) * m;
119
const y = startPoint.y + normal.y * i - (ay - by) * m;
120
121
this.path.add(new Point(x, y));
122
}
123
124
this.children.forEach((child) => {
125
child.speed = this.speed * 1.35;
126
child.path.strokeWidth = Math.max(
127
this.path.strokeWidth * Math.random() * 1,
128
0.5
129
);
130
child.update();
131
});
132
}
133
134
private noise(v: number) {
135
var octaves = 6,
136
fallout = 0.5,
137
amp = 1,
138
f = 1,
139
sum = 0,
140
i;
141
142
for (i = 0; i < octaves; ++i) {
143
amp *= fallout;
144
sum += amp * (this._simplexNoise(v * f, 0) + 1) * 0.5;
145
f *= 2;
146
}
147
148
return sum;
149
}
150
}

The key to this animation is simplex noise, a coherent noise function that generates a more naturally ordered, smooth randomness compared to traditional random number generators. This is essential in simulating the natural, unpredictable pathway of a lightning bolt. Another great option would have been perlin noise, I picked simplex noise here simply because i stumbled upon the simplex-noise package on npm.The following line creates a generator function that can be called to obtain a random value that follows a simplex noise distribution.

javascript
1
private \_simplexNoise = createNoise2D();

Constructing the lightning path with simplex noise

The path of the lightning bolt is computed in the update method, where Simplex Noise is leveraged to create an organically random path. The noise influences the bolt's width, thus contributing to the distinctive, jagged appearance of lightning.

javascript
1
let waveWidth = (this.parent ? length * 1.5 : length) * this.amplitude;
2
if (waveWidth > 750) waveWidth = 750;
3
this.path.segments = [];
4
for (let i = 0, len = this.step + 1; i < len; i++) {
5
...
6
this.path.add(new Point(x, y));
7
}

The above code is adding points to the path of our lightning bolt, with each point’s position determined by the Simplex Noise. These points form the basis of the lightning bolt, and due to the randomness, each bolt generated is unique.

The parent-child dynamic The Lightning class also employs parent-child relationships to add complexity and depth to the rendered lightning. The class generates child lightning bolts that branch out from the main bolt simulating the fractal nature of real lightning.

javascript
1
for (let i = 0; i < this.childCount; i++) {
2
const child = new Lightning();
3
child.setAsChild(this);
4
this.children.push(child);
5
}

In the constructor of the Lightning class, child lightning instances are created and associated with their parent. These child bolts start and end at different points along the parent bolt, typically featuring a smaller stroke width than the parent to maintain the visual weight of the primary bolt.

Rendering with Paper.js

A significant benefit of using Paper.js is the abstraction it provides from the actual drawing commands issued to the Canvas. Rather than worrying about the intricacies of rendering, we can focus on manipulating the properties of the Paper.js objects, and the library's internal render loop takes care of reflecting these updates on the screen.

javascript
1
this.path = new Path({
2
strokeColor: "black",
3
strokeWidth: 3,
4
shadowBlur: 5,
5
shadowColor: "black",
6
segments: [],
7
});

In the above code snippet, we can see the creation of a new Paper.js Path object. This object represents our lightning bolt. We're setting its color, stroke width, and shadow properties, but we don't need to worry about transferring these properties onto the screen using the actual canvas API draw calls.

Reacting to User Interaction

The beauty of this animation is that it's interactive, reacting to user scroll events. The way this works is that the application is rendering a section tag with a width of 100% and a height several times the height of the viewport. The user then scrolls within this section and the application computes the fraction of the user's scroll position within it, yielding a value between 0 and 1.

javascript
1
const EndSection = () => {
2
const { ref, value } = useScrollPosition();
3
return (
4
<section ref={ref} className="w-full h-[1200vh] -mt-[100vh]">
5
<Lightnings value={value} />
6
</section>
7
);
8
};

This fraction can then be used to interpolate between different states or style properties. We can make elements appear, fade out, translate them etc. For the lightning animation we discuss here, it is enough to simply listen to change events in the value and fire the update method in response. With that, each change in the scroll position leads to the lightning bolt's path being recomputed and updated, fostering a sense of interactivity and engagement.The main logic then resides in React effects. There is an effect that is run to create the initial scene, the resulting paper.js objects are stored in a React ref. Then there is this second effect that is rerun whenever the percent value changes. Here is where we update our lightnings:

javascript
1
React.useEffect(() => {
2
const { lightnings, blackhole } = stateRef.current;
3
const start = 0.3;
4
lightnings.forEach((l) => {
5
if (value < start || value >= 1) {
6
l.setOpacity(0);
7
} else {
8
l.setOpacity(1);
9
}
10
l.update();
11
});
12
}, [value]);

We can also see that we set the lightning's opacity to zero whenever the value is either below the starting value of 0.3 or above or equal to 1. This makes sure the lightning is only visible on screen for a specific portion of the full scroll height.

Conclusion

With the synergy of Paper.js and TypeScript, this dynamic, procedurally generated lightning animation delivers an immersive, interactive user experience. Understanding the interplay of Paper.js, Simplex Noise, and user interaction provides an insight into the power and versatility of web animations.