Animation-heavy personal portfolio on Next.js: a custom cursor, a page-transition loader, and canvas backgrounds.
canvas-animationscss-modulesdark-themehandcodednextjspersonal-websiteportfolioreact
1import React, { useEffect, useRef } from "react";
2import styles from "@styles/components/canvas.module.css";
3import PropTypes from "prop-types";
4
5const SlimeMold = ({ options }) => {
6 const isActive = options.isActive !== undefined ? options.isActive : true;
7 const numAgents = options.numAgents || 6000;
8 const canvas = useRef(null);
9
10 useEffect(() => {
11 const cvs = canvas.current;
12
13 cvs.width = cvs.offsetWidth;
14 cvs.height = cvs.offsetHeight;
15
16 const resizeCanvas = () => {
17 cvs.width = cvs.offsetWidth;
18 cvs.height = cvs.offsetHeight;
19 };
20 window.addEventListener("resize", resizeCanvas);
21
22 return () => {
23 window.removeEventListener("resize", resizeCanvas);
24 };
25 }, []);
26
27 useEffect(() => {
28 const cvs = canvas.current;
29 const ctx = cvs.getContext("2d");
30
31 const scale = 2;
32 const tw = Math.max(1, Math.floor(cvs.width / scale));
33 const th = Math.max(1, Math.floor(cvs.height / scale));
34 let trail = new Float32Array(tw * th);
35 let trailNext = new Float32Array(tw * th);
36
37 const agents = new Array(numAgents);
38 for (let i = 0; i < numAgents; i++) {
39 const cx = tw / 2;
40 const cy = th / 2;
41 const r = Math.sqrt(Math.random()) * Math.min(tw, th) * 0.4;
42 const a = Math.random() * Math.PI * 2;
43 const x = cx + Math.cos(a) * r;
44 const y = cy + Math.sin(a) * r;
45 agents[i] = {
46 x,
47 y,
48 heading: Math.atan2(cy - y, cx - x),
49 };
50 }
51
52 const sensorAngle = Math.PI / 4;
53 const sensorDistance = 9;
54 const rotationAngle = Math.PI / 8;
55 const moveSpeed = 1;
56 const decay = 0.96;
57 const depositAmount = 0.5;
58
59 const sense = (x, y, heading, offset) => {
60 const angle = heading + offset;
61 const sx = Math.floor(x + Math.cos(angle) * sensorDistance);
62 const sy = Math.floor(y + Math.sin(angle) * sensorDistance);
63 if (sx < 0 || sx >= tw || sy < 0 || sy >= th) return -1;
64 return trail[sy * tw + sx];
65 };
66
67 const stops = [
68 [0.0, 0, 0, 0],
69 [0.35, 14, 63, 244],
70 [0.7, 132, 43, 255],
71 [1.0, 255, 255, 255],
72 ];
73 const colorLUT = new Uint8ClampedArray(256 * 3);
74 for (let i = 0; i < 256; i++) {
75 const t = i / 255;
76 let s = 0;
77 while (s < stops.length - 2 && t > stops[s + 1][0]) s++;
78 const [t0, r0, g0, b0] = stops[s];
79 const [t1, r1, g1, b1] = stops[s + 1];
80 const k = (t - t0) / (t1 - t0);
81 colorLUT[i * 3] = Math.round(r0 + (r1 - r0) * k);
82 colorLUT[i * 3 + 1] = Math.round(g0 + (g1 - g0) * k);
83 colorLUT[i * 3 + 2] = Math.round(b0 + (b1 - b0) * k);
84 }
85
86 const offCanvas = document.createElement("canvas");
87 offCanvas.width = tw;
88 offCanvas.height = th;
89 const offCtx = offCanvas.getContext("2d");
90 const offImageData = offCtx.createImageData(tw, th);
91 const offData = offImageData.data;
92
93 let animationFrame = null;
94
95 const step = () => {
96 for (let i = 0; i < numAgents; i++) {
97 const agent = agents[i];
98 const f = sense(agent.x, agent.y, agent.heading, 0);
99 const l = sense(agent.x, agent.y, agent.heading, -sensorAngle);
100 const r = sense(agent.x, agent.y, agent.heading, sensorAngle);
101
102 if (f > l && f > r) {
103 // continue straight
104 } else if (f < l && f < r) {
105 agent.heading += (Math.random() - 0.5) * 2 * rotationAngle;
106 } else if (l > r) {
107 agent.heading -= rotationAngle;
108 } else if (r > l) {
109 agent.heading += rotationAngle;
110 }
111
112 agent.x += Math.cos(agent.heading) * moveSpeed;
113 agent.y += Math.sin(agent.heading) * moveSpeed;
114
115 if (agent.x < 0 || agent.x >= tw || agent.y < 0 || agent.y >= th) {
116 agent.x = Math.max(0, Math.min(tw - 1, agent.x));
117 agent.y = Math.max(0, Math.min(th - 1, agent.y));
118 agent.heading = Math.random() * Math.PI * 2;
119 }
120
121 const idx = Math.floor(agent.y) * tw + Math.floor(agent.x);
122 trail[idx] = Math.min(1, trail[idx] + depositAmount);
123 }
124
125 for (let y = 0; y < th; y++) {
126 for (let x = 0; x < tw; x++) {
127 const x0 = x === 0 ? x : x - 1;
128 const x2 = x === tw - 1 ? x : x + 1;
129 const y0 = y === 0 ? y : y - 1;
130 const y2 = y === th - 1 ? y : y + 1;
131 const sum =
132 trail[y0 * tw + x0] +
133 trail[y0 * tw + x] +
134 trail[y0 * tw + x2] +
135 trail[y * tw + x0] +
136 trail[y * tw + x] +
137 trail[y * tw + x2] +
138 trail[y2 * tw + x0] +
139 trail[y2 * tw + x] +
140 trail[y2 * tw + x2];
141 trailNext[y * tw + x] = (sum / 9) * decay;
142 }
143 }
144 const tmp = trail;
145 trail = trailNext;
146 trailNext = tmp;
147
148 const len = tw * th;
149 for (let i = 0; i < len; i++) {
150 const v = trail[i];
151 const ci =
152 (v >= 1 ? 255 : v <= 0 ? 0 : Math.floor(Math.pow(v, 0.6) * 255)) * 3;
153 const di = i * 4;
154 offData[di] = colorLUT[ci];
155 offData[di + 1] = colorLUT[ci + 1];
156 offData[di + 2] = colorLUT[ci + 2];
157 offData[di + 3] = 255;
158 }
159 offCtx.putImageData(offImageData, 0, 0);
160
161 ctx.imageSmoothingEnabled = true;
162 ctx.imageSmoothingQuality = "high";
163 ctx.drawImage(offCanvas, 0, 0, cvs.width, cvs.height);
164
165 if (isActive) {
166 animationFrame = window.requestAnimationFrame(step);
167 }
168 };
169
170 if (isActive) {
171 animationFrame = window.requestAnimationFrame(step);
172 }
173
174 return () => {
175 if (animationFrame) {
176 window.cancelAnimationFrame(animationFrame);
177 }
178 };
179 }, [isActive, numAgents]);
180
181 return <canvas ref={canvas} className={styles.canvas} />;
182};
183
184SlimeMold.propTypes = {
185 options: PropTypes.object,
186};
187
188export default SlimeMold;