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
1class Lightning {2public path: paper.Path;3public speed = 0.025;45private startPoint: paper.Point;6private endPoint: paper.Point;7private step: number;8private amplitude = 1;9private off = 0;10private _simplexNoise = createNoise2D();1112// child stuff13private parent: Lightning | null = null;14private children: Lightning[];15private childCount: number;16private startStep = 0;17private endStep = 0;1819constructor({20startPoint,21endPoint,22childCount,23}: {24startPoint?: paper.Point;25endPoint?: paper.Point;26childCount?: number;27} = {}) {28this.startPoint = startPoint ?? new Point(0, 0);29this.endPoint = endPoint ?? new Point(0, 0);30this.childCount = childCount ?? 0;31this.step = 45;32this.path = new Path({33strokeColor: "black",34strokeWidth: 3,35shadowBlur: 5,36shadowColor: "black",37segments: [],38});3940this.children = [];41for (let i = 0; i < this.childCount; i++) {42const child = new Lightning();43child.setAsChild(this);44this.children.push(child);45}46}4748public setAsChild(lightning: Lightning) {49if (!(lightning instanceof Lightning)) return;50this.parent = lightning;5152const setTimer = () => {53this.updateStepsByParent();54setTimeout(setTimer, randomInt(1500));55};5657setTimeout(setTimer, randomInt(1500));58}5960private updateStepsByParent() {61if (!this.parent) return;62var parentStep = this.parent.step;63this.startStep = randomInt(parentStep - 2);64this.endStep =65this.startStep + randomInt(parentStep - this.startStep - 2) + 2;66this.step = this.endStep - this.startStep;67}6869public length() {70return this.endPoint.subtract(this.startPoint).length;71}7273public setOpacity(o: number) {74this.path.opacity = o;75this.children.forEach((c) => (c.path.opacity = o));76}7778public update() {79const startPoint = this.startPoint;80const endPoint = this.endPoint;8182if (this.parent) {83if (this.endStep > this.parent.step) {84this.updateStepsByParent();85}8687startPoint.set(this.parent.path.segments[this.startStep].point);88endPoint.set(this.parent.path.segments[this.endStep].point);89}9091const length = this.length();92const normal = endPoint93.subtract(startPoint)94.normalize()95.multiply(length / this.step);96const radian = toRadians(normal.angle);97const sinv = Math.sin(radian);98const cosv = Math.cos(radian);99100const off = (this.off += random(this.speed, this.speed * 0.2));101let waveWidth = (this.parent ? length * 1.5 : length) * this.amplitude;102if (waveWidth > 750) waveWidth = 750;103104this.path.segments = [];105106for (let i = 0, len = this.step + 1; i < len; i++) {107const n = i / 60;108const av = waveWidth * this.noise(n - off) * 0.5;109const ax = sinv * av;110const ay = cosv * av;111112const bv = waveWidth * this.noise(n + off) * 0.5;113const bx = sinv * bv;114const by = cosv * bv;115116const m = Math.sin(Math.PI * (i / (len - 1)));117118const x = startPoint.x + normal.x * i + (ax - bx) * m;119const y = startPoint.y + normal.y * i - (ay - by) * m;120121this.path.add(new Point(x, y));122}123124this.children.forEach((child) => {125child.speed = this.speed * 1.35;126child.path.strokeWidth = Math.max(127this.path.strokeWidth * Math.random() * 1,1280.5129);130child.update();131});132}133134private noise(v: number) {135var octaves = 6,136fallout = 0.5,137amp = 1,138f = 1,139sum = 0,140i;141142for (i = 0; i < octaves; ++i) {143amp *= fallout;144sum += amp * (this._simplexNoise(v * f, 0) + 1) * 0.5;145f *= 2;146}147148return 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
1private \_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
1let waveWidth = (this.parent ? length * 1.5 : length) * this.amplitude;2if (waveWidth > 750) waveWidth = 750;3this.path.segments = [];4for (let i = 0, len = this.step + 1; i < len; i++) {5...6this.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
1for (let i = 0; i < this.childCount; i++) {2const child = new Lightning();3child.setAsChild(this);4this.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
1this.path = new Path({2strokeColor: "black",3strokeWidth: 3,4shadowBlur: 5,5shadowColor: "black",6segments: [],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
1const EndSection = () => {2const { ref, value } = useScrollPosition();3return (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
1React.useEffect(() => {2const { lightnings, blackhole } = stateRef.current;3const start = 0.3;4lightnings.forEach((l) => {5if (value < start || value >= 1) {6l.setOpacity(0);7} else {8l.setOpacity(1);9}10l.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.