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 RetroStars = ({ options }) => {
6 const isActive = options.isActive !== undefined ? options.isActive : true;
7 const canvas = useRef(null);
8 // Refs, not state: nothing rendered by React depends on these (the canvas
9 // loop reads them directly), and state would re-render the component on
10 // every mousemove and every 500ms twinkle tick.
11 const mousePositionRef = useRef({ x: 640, y: 400 });
12 const starSizeRef = useRef(0);
13
14 const offsetStar = (maxOffset) => {
15 return {
16 x: -Math.floor(
17 (mousePositionRef.current.x / window.innerWidth) * maxOffset
18 ),
19 y: -Math.floor(
20 (mousePositionRef.current.y / window.innerHeight) * maxOffset
21 ),
22 };
23 };
24
25 useEffect(() => {
26 const mouseMove = (evt) => {
27 mousePositionRef.current = {
28 x: evt.clientX,
29 y: evt.clientY,
30 };
31 };
32
33 window.addEventListener("mousemove", mouseMove);
34
35 return () => {
36 window.removeEventListener("mousemove", mouseMove);
37 };
38 }, []);
39
40 useEffect(() => {
41 const cvs = canvas.current;
42
43 cvs.width = cvs.offsetWidth;
44 cvs.height = cvs.offsetHeight;
45
46 const resizeCanvas = () => {
47 cvs.width = cvs.offsetWidth;
48 cvs.height = cvs.offsetHeight;
49 };
50 window.addEventListener("resize", resizeCanvas);
51
52 return () => {
53 window.removeEventListener("resize", resizeCanvas);
54 };
55 }, []);
56
57 useEffect(() => {
58 let starSizeInterval = null;
59 if (isActive) {
60 starSizeInterval = setInterval(() => {
61 starSizeRef.current = (starSizeRef.current + 1) % 4;
62 }, 500);
63 }
64
65 return () => {
66 if (starSizeInterval) {
67 clearInterval(starSizeInterval);
68 }
69 };
70 }, [isActive]);
71
72 useEffect(() => {
73 const cvs = canvas.current;
74 const ctx = cvs.getContext("2d");
75
76 const colors = ["white", "yellow", "red", "green", "blue"];
77
78 let smallStars = [];
79 let mediumStars = [];
80 let largeStars = [];
81 let numStars = 0;
82 const maxNumStars = options.numStars;
83 while (numStars < maxNumStars) {
84 smallStars.push({
85 loc: [cvs.width * Math.random(), cvs.height * Math.random()],
86 color: colors[Math.floor(Math.random() * colors.length)],
87 });
88 if (mediumStars.length < numStars / 4) {
89 mediumStars.push({
90 loc: [cvs.width * Math.random(), cvs.height * Math.random()],
91 color: colors[Math.floor(Math.random() * colors.length)],
92 });
93 }
94 if (largeStars.length < numStars / 8) {
95 largeStars.push({
96 loc: [cvs.width * Math.random(), cvs.height * Math.random()],
97 color: colors[Math.floor(Math.random() * colors.length)],
98 });
99 }
100 numStars++;
101 }
102
103 let starsAnimationFrame = null;
104 const drawStars = () => {
105 ctx.clearRect(0, 0, cvs.width, cvs.height);
106
107 const smallStarOffset = offsetStar(25);
108 const mediumStarOffset = offsetStar(75);
109 const largeStarOffset = offsetStar(125);
110
111 smallStars.forEach(({ loc, color }) => {
112 ctx.beginPath();
113 ctx.fillStyle = color;
114 ctx.fillRect(
115 loc[0] + smallStarOffset.x,
116 loc[1] + smallStarOffset.y,
117 5,
118 5
119 );
120 ctx.closePath();
121 });
122 mediumStars.forEach(({ loc, color }) => {
123 if (starSizeRef.current === 0 || starSizeRef.current === 2) {
124 ctx.beginPath();
125 ctx.fillStyle = color;
126 ctx.fillRect(
127 loc[0] + mediumStarOffset.x,
128 loc[1] + mediumStarOffset.y,
129 5,
130 5
131 );
132 ctx.closePath();
133 } else {
134 const squares = [
135 [loc[0] + mediumStarOffset.x, loc[1] + mediumStarOffset.y],
136 [loc[0] - 5 + mediumStarOffset.x, loc[1] + mediumStarOffset.y],
137 [loc[0] + mediumStarOffset.x, loc[1] - 5 + mediumStarOffset.y],
138 [loc[0] + 5 + mediumStarOffset.x, loc[1] + mediumStarOffset.y],
139 [loc[0] + mediumStarOffset.x, loc[1] + 5 + mediumStarOffset.y],
140 ];
141 squares.forEach((square) => {
142 ctx.beginPath();
143 ctx.fillStyle = color;
144 ctx.fillRect(...square, 5, 5);
145 ctx.closePath();
146 });
147 }
148 });
149 largeStars.forEach(({ loc, color }) => {
150 if (starSizeRef.current === 0) {
151 ctx.beginPath();
152 ctx.fillStyle = color;
153 ctx.fillRect(
154 loc[0] + largeStarOffset.x,
155 loc[1] + largeStarOffset.y,
156 5,
157 5
158 );
159 ctx.closePath();
160 } else if (starSizeRef.current === 1 || starSizeRef.current === 3) {
161 const squares = [
162 [loc[0] + largeStarOffset.x, loc[1] + largeStarOffset.y],
163 [loc[0] - 5 + largeStarOffset.x, loc[1] + largeStarOffset.y],
164 [loc[0] + largeStarOffset.x, loc[1] - 5 + largeStarOffset.y],
165 [loc[0] + 5 + largeStarOffset.x, loc[1] + largeStarOffset.y],
166 [loc[0] + largeStarOffset.x, loc[1] + 5 + largeStarOffset.y],
167 ];
168 squares.forEach((square) => {
169 ctx.beginPath();
170 ctx.fillStyle = color;
171 ctx.fillRect(...square, 5, 5);
172 ctx.closePath();
173 });
174 } else {
175 const squares = [
176 [loc[0] - 10 + largeStarOffset.x, loc[1] + largeStarOffset.y],
177 [loc[0] + largeStarOffset.x, loc[1] - 10 + largeStarOffset.y],
178 [loc[0] + 10 + largeStarOffset.x, loc[1] + largeStarOffset.y],
179 [loc[0] + largeStarOffset.x, loc[1] + 10 + largeStarOffset.y],
180 ];
181 ctx.beginPath();
182 ctx.fillStyle = color;
183 ctx.fillRect(
184 loc[0] - 5 + largeStarOffset.x,
185 loc[1] - 5 + largeStarOffset.y,
186 15,
187 15
188 );
189 ctx.closePath();
190 squares.forEach((square) => {
191 ctx.beginPath();
192 ctx.fillStyle = color;
193 ctx.fillRect(...square, 5, 5);
194 ctx.closePath();
195 });
196 ctx.beginPath();
197 ctx.fillStyle = "black";
198 ctx.fillRect(
199 loc[0] + largeStarOffset.x,
200 loc[1] + largeStarOffset.y,
201 5,
202 5
203 );
204 ctx.closePath();
205 }
206 });
207
208 if (isActive) {
209 starsAnimationFrame = window.requestAnimationFrame(drawStars);
210 }
211 };
212
213 if (isActive) {
214 starsAnimationFrame = window.requestAnimationFrame(drawStars);
215 }
216
217 return () => {
218 window.cancelAnimationFrame(starsAnimationFrame);
219 };
220 }, [isActive]);
221
222 return <canvas ref={canvas} className={styles.canvas} />;
223};
224
225RetroStars.propTypes = {
226 options: PropTypes.object,
227};
228
229export default RetroStars;