repos

isaacbythewood.com-nextjs

mirror archived upstream

Animation-heavy personal portfolio on Next.js: a custom cursor, a page-transition loader, and canvas backgrounds.

canvas-animationscss-modulesdark-themehandcodednextjspersonal-websiteportfolioreact

6.7 KB · 230 lines · JavaScript Raw History
  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;