Animation-heavy personal portfolio on Next.js: a custom cursor, a page-transition loader, and canvas backgrounds.
canvas-animationscss-modulesdark-themehandcodednextjspersonal-websiteportfolioreact
1import React, { useState, useEffect, useRef } from "react";
2import { createPortal } from "react-dom";
3import styles from "@styles/pages/art.module.css";
4import Image from "next/image";
5
6import Page from "../components/page";
7import Constellations from "../components/constellations";
8import RetroStars from "../components/retrostars";
9import SlimeMold from "../components/slimemold";
10
11const ArtCard = ({ src, alt, title, number, priority, onClick }) => {
12 const [loaded, setLoaded] = useState(false);
13 return (
14 <div className={styles.artItem} onClick={onClick}>
15 <span className={styles.cardImage}>
16 <Image
17 src={src}
18 alt={alt}
19 width={640}
20 height={360}
21 className={`mouse-activate ${loaded ? styles.imgLoaded : ""}`}
22 priority={priority}
23 onLoad={() => setLoaded(true)}
24 />
25 </span>
26 <h2 className={styles.artItemHeader}>
27 {title} <span>{number}</span>
28 </h2>
29 </div>
30 );
31};
32
33const ACRYLIC_POURS = [
34 { number: "006", title: "Molten Copper", priority: true },
35 { number: "005", title: "Nebulas in Triangulum", priority: true },
36 { number: "004", title: "Metal on Mars", priority: true },
37 { number: "003", title: "Water on Jupiter", priority: true },
38 { number: "002", title: "Cracks in Clay", priority: false },
39 { number: "001", title: "Reef Drop-off", priority: false },
40 { number: "000", title: "Blood in Waves", priority: false },
41];
42
43const Art = () => {
44 const [lightboxImage, setLightboxImage] = useState(null);
45 const [lightboxLoaded, setLightboxLoaded] = useState(false);
46 const [activeArt, setActiveArt] = useState("constellations");
47 const [mounted, setMounted] = useState(false);
48
49 useEffect(() => {
50 setMounted(true);
51 }, []);
52
53 const openLightbox = (image) => {
54 setLightboxImage(image);
55 document.body.style.overflowY = "hidden";
56 };
57
58 const closeLightbox = () => {
59 setLightboxImage(null);
60 setLightboxLoaded(false);
61 history.replaceState(null, null, " ");
62 document.body.style.overflowY = "scroll";
63 };
64
65 const handleArtToggle = (artName) => {
66 setActiveArt(activeArt === artName ? null : artName);
67 };
68
69 return (
70 <Page title="Art" description="Some of my art... what even is art...">
71 <div className={styles.background} />
72 <h1 className={styles.heading}>Acrylic Pours</h1>
73 <p className={styles.paragraph}>
74 A bit more traditional than my usual art. Each piece starts as a mix of
75 acrylic paint, water, glue, and silicone oil poured onto canvas, then
76 manipulated with a heat gun to bring out cells and organic patterns. No
77 two pours come out the same and the process is as much about letting go
78 of control as it is about technique.
79 </p>
80 <div className={styles.artGrid}>
81 {ACRYLIC_POURS.map(({ number, title, priority }) => {
82 const src = `/static/images/art/acrylic-pours/${number}.webp`;
83 return (
84 <ArtCard
85 key={number}
86 src={src}
87 alt={title}
88 title={title}
89 number={number}
90 priority={priority}
91 onClick={() => openLightbox(src)}
92 />
93 );
94 })}
95 </div>
96 <h1 className={styles.heading}>Emergent Generative Art</h1>
97 <p className={styles.paragraph}>
98 Code as a creative medium. These pieces are built entirely in JavaScript
99 on HTML canvas, each one a small system of simple rules that produces
100 complex, unpredictable behavior. The art emerges from the interaction of
101 autonomous entities rather than being explicitly drawn.
102 </p>
103 <h2 className={styles.subheading}>
104 <span>000</span> Constellations
105 </h2>
106 <p className={styles.paragraph}>
107 Drifting points on a dark canvas that form connections with their nearest
108 neighbors. As stars wander closer together lines appear between them,
109 brighter the nearer they get, building and dissolving constellations
110 that never repeat.
111 </p>
112 <div className={styles.artContainer}>
113 <Constellations
114 options={{ numStars: 50, isActive: activeArt === "constellations" }}
115 />
116 <button
117 className={styles.playButton}
118 onClick={() => handleArtToggle("constellations")}
119 data-active={activeArt === "constellations"}
120 >
121 {activeArt === "constellations" ? "⏸" : "▶"}
122 </button>
123 </div>
124 <a
125 className={styles.artItemButton}
126 href="https://github.com/overshard/isaacbythewood.com-nextjs/blob/master/components/constellations.js"
127 rel="noopener noreferrer"
128 target="_blank"
129 >
130 See the Code
131 </a>
132 <h2 className={styles.subheading}>
133 <span>001</span> Retro Stars
134 </h2>
135 <p className={styles.paragraph}>
136 Layered star fields at different depths that drift in response to your
137 cursor, creating a parallax effect. Inspired by the pixel art aesthetic
138 of Celeste and the feeling of staring into a sky that moves with you.
139 </p>
140 <div className={styles.artContainer}>
141 <RetroStars
142 options={{ numStars: 50, isActive: activeArt === "retrostars" }}
143 />
144 <button
145 className={styles.playButton}
146 onClick={() => handleArtToggle("retrostars")}
147 data-active={activeArt === "retrostars"}
148 >
149 {activeArt === "retrostars" ? "⏸" : "▶"}
150 </button>
151 </div>
152 <a
153 className={styles.artItemButton}
154 href="https://github.com/overshard/isaacbythewood.com-nextjs/blob/master/components/retrostars.js"
155 rel="noopener noreferrer"
156 target="_blank"
157 >
158 See the Code
159 </a>
160 <h2 className={styles.subheading}>
161 <span>002</span> Slime Mold
162 </h2>
163 <p className={styles.paragraph}>
164 Thousands of agents wander a dark field, each leaving a faint chemical
165 trail and steering toward the strongest scent ahead. From three simple
166 rules — sense, turn, deposit — emerge living networks reminiscent of
167 Physarum slime molds, neurons, and the cosmic web. The pattern never
168 settles; trails decay as fast as they form.
169 </p>
170 <div className={styles.artContainer}>
171 <SlimeMold options={{ isActive: activeArt === "slimemold" }} />
172 <button
173 className={styles.playButton}
174 onClick={() => handleArtToggle("slimemold")}
175 data-active={activeArt === "slimemold"}
176 >
177 {activeArt === "slimemold" ? "⏸" : "▶"}
178 </button>
179 </div>
180 <a
181 className={styles.artItemButton}
182 href="https://github.com/overshard/isaacbythewood.com-nextjs/blob/master/components/slimemold.js"
183 rel="noopener noreferrer"
184 target="_blank"
185 >
186 See the Code
187 </a>
188 {mounted && lightboxImage !== null && createPortal(
189 <div className={styles.lightboxOverlay} onClick={() => closeLightbox()}>
190 <span
191 className={styles.lightboxImageWrapper}
192 onClick={(e) => e.stopPropagation()}
193 >
194 <div
195 className={
196 styles.lightboxLoading +
197 (lightboxLoaded ? " " + styles.hide : "")
198 }
199 >
200 Loading...
201 </div>
202 <Image
203 className={
204 styles.lightboxImage + (lightboxLoaded ? " " + styles.show : "")
205 }
206 src={lightboxImage}
207 alt="Lightbox"
208 fill
209 sizes="90vw"
210 style={{ objectFit: "contain" }}
211 onLoad={() => setLightboxLoaded(true)}
212 />
213 <button
214 className={styles.lightboxClose}
215 onClick={() => closeLightbox()}
216 >
217 ✕
218 </button>
219 </span>
220 </div>,
221 document.body
222 )}
223 </Page>
224 );
225};
226
227export default Art;