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 { TransitionGroup, CSSTransition } from "react-transition-group";
3
4import Page from "../components/page";
5import styles from "@styles/pages/about.module.css";
6
7const INITIAL_WORDS = [
8 "AI Agents",
9 "DevOps",
10 "Full-Stack",
11 "Testing",
12 "Cloud",
13 "Security",
14 "Developer",
15 "Automation",
16 "Architecture",
17];
18
19const About = () => {
20 const [words, setWords] = useState(INITIAL_WORDS);
21 const wordsRef = useRef(words);
22 wordsRef.current = words;
23 const slots = useRef(
24 Object.fromEntries(INITIAL_WORDS.map((word, i) => [word, i]))
25 );
26 const wordRefs = useRef(new Map());
27 const getWordRef = (key) => {
28 if (!wordRefs.current.has(key)) {
29 wordRefs.current.set(key, React.createRef());
30 }
31 return wordRefs.current.get(key);
32 };
33
34 useEffect(() => {
35 const getRandomWordIndex = () => {
36 return Math.floor(Math.random() * wordsRef.current.length);
37 };
38
39 const wordsInterval = setInterval(() => {
40 let firstWordIndex = getRandomWordIndex();
41 let secondWordIndex = getRandomWordIndex();
42 while (firstWordIndex === secondWordIndex) {
43 firstWordIndex = getRandomWordIndex();
44 secondWordIndex = getRandomWordIndex();
45 }
46 const firstWord = wordsRef.current[firstWordIndex];
47 const secondWord = wordsRef.current[secondWordIndex];
48 const firstSlot = slots.current[firstWord];
49 const secondSlot = slots.current[secondWord];
50 slots.current[firstWord] = secondSlot;
51 slots.current[secondWord] = firstSlot;
52 setWords(
53 wordsRef.current.filter((word, index) => {
54 return index !== firstWordIndex && index !== secondWordIndex;
55 })
56 );
57 setTimeout(() => {
58 let newWords = [...wordsRef.current];
59 newWords.splice(secondWordIndex, 0, firstWord);
60 newWords.splice(firstWordIndex, 0, secondWord);
61 setWords(newWords);
62 }, 1000);
63 }, 4000);
64
65 return () => {
66 clearInterval(wordsInterval);
67 };
68 }, []);
69
70 return (
71 <Page title="About" description="A brief professional history of myself.">
72 <div className={styles.background} />
73 <div className={styles.words}>
74 <TransitionGroup component={null}>
75 {words.map((word) => {
76 const nodeRef = getWordRef(word);
77 return (
78 <CSSTransition
79 key={word}
80 timeout={1000}
81 appear
82 classNames={{
83 appearActive: styles.wordAppearActive,
84 appearDone: styles.wordAppearDone,
85 enterActive: styles.wordEnterActive,
86 enterDone: styles.wordEnterDone,
87 exitActive: styles.wordExitActive,
88 }}
89 nodeRef={nodeRef}
90 >
91 <h2
92 className={styles.word}
93 ref={nodeRef}
94 style={{ top: `${slots.current[word] * 11}vh` }}
95 >
96 <span className={styles.wordText}>{word}</span>
97 </h2>
98 </CSSTransition>
99 );
100 })}
101 </TransitionGroup>
102 </div>
103 <p className={styles.paragraph}>
104 I am a{" "}
105 <span className={styles.strong}>senior solutions architect</span>{" "}
106 at Craftmaster Furniture with{" "}
107 <span className={styles.strong}>two decades in tech</span>.{" "}
108 I got my start{" "}
109 <span className={styles.strong}>modifying kernel modules</span>{" "}
110 for Digium Telephony Cards on CentOS and have been building across the{" "}
111 <span className={styles.strong}>full stack</span>{" "}
112 ever since, from DevOps and server infrastructure to{" "}
113 <span className={styles.strong}>highly regulated</span>{" "}
114 healthcare environments, leading teams through tight deadlines and
115 demanding delivery schedules. Today I focus on{" "}
116 <span className={styles.strong}>AI agent workflows</span>,{" "}
117 automated testing infrastructure, and tooling that keeps release cycles
118 fast without sacrificing stability or security.
119 <a
120 className={styles.resume}
121 href="/static/pdfs/resume-isaac-bythewood.pdf"
122 target="_blank"
123 rel="noopener noreferrer"
124 >
125 <svg
126 xmlns="http://www.w3.org/2000/svg"
127 width="20"
128 height="20"
129 fill="currentColor"
130 viewBox="0 0 16 16"
131 >
132 <path
133 fillRule="evenodd"
134 d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5L14 4.5ZM1.6 11.85H0v3.999h.791v-1.342h.803c.287 0 .531-.057.732-.173.203-.117.358-.275.463-.474a1.42 1.42 0 0 0 .161-.677c0-.25-.053-.476-.158-.677a1.176 1.176 0 0 0-.46-.477c-.2-.12-.443-.179-.732-.179Zm.545 1.333a.795.795 0 0 1-.085.38.574.574 0 0 1-.238.241.794.794 0 0 1-.375.082H.788V12.48h.66c.218 0 .389.06.512.181.123.122.185.296.185.522Zm1.217-1.333v3.999h1.46c.401 0 .734-.08.998-.237a1.45 1.45 0 0 0 .595-.689c.13-.3.196-.662.196-1.084 0-.42-.065-.778-.196-1.075a1.426 1.426 0 0 0-.589-.68c-.264-.156-.599-.234-1.005-.234H3.362Zm.791.645h.563c.248 0 .45.05.609.152a.89.89 0 0 1 .354.454c.079.201.118.452.118.753a2.3 2.3 0 0 1-.068.592 1.14 1.14 0 0 1-.196.422.8.8 0 0 1-.334.252 1.298 1.298 0 0 1-.483.082h-.563v-2.707Zm3.743 1.763v1.591h-.79V11.85h2.548v.653H7.896v1.117h1.606v.638H7.896Z"
135 />
136 </svg>
137 My Resume
138 </a>
139 </p>
140 </Page>
141 );
142};
143
144export default About;