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, useState, useRef } from "react";
2import { TransitionGroup, CSSTransition } from "react-transition-group";
3import Image from "next/image";
4
5import Page from "../components/page";
6import styles from "@styles/pages/index.module.css";
7
8const Index = () => {
9 const words = ["AI Agents", "Automation", "DevOps", "Architecture"];
10 const [currentWords, setCurrentWord] = useState([words[0]]);
11 const [imageLoaded, setImageLoaded] = useState(false);
12 const currentWordsRef = useRef(currentWords);
13 currentWordsRef.current = currentWords;
14 const wordRefs = useRef(new Map());
15 const getWordRef = (key) => {
16 if (!wordRefs.current.has(key)) {
17 wordRefs.current.set(key, React.createRef());
18 }
19 return wordRefs.current.get(key);
20 };
21
22 useEffect(() => {
23 const wordsInterval = setInterval(() => {
24 let nextWordIndex = words.indexOf(currentWordsRef.current[0]) + 1;
25 if (nextWordIndex < words.length) setCurrentWord([words[nextWordIndex]]);
26 else setCurrentWord([words[0]]);
27 }, 2000);
28 return () => {
29 clearInterval(wordsInterval);
30 };
31 }, []);
32
33 const playState = imageLoaded ? "running" : "paused";
34
35 return (
36 <Page title="Senior Solutions Architect at Craftmaster Furniture">
37 <div className={styles.imageWrapper}>
38 <Image
39 src="/static/images/art/acrylic-pours/005.webp"
40 alt="#005 Nebulas in Triangulum"
41 loading="eager"
42 priority={true}
43 fill
44 style={{ objectFit: "cover", objectPosition: "center" }}
45 onLoad={() => {
46 setImageLoaded(true);
47 window.dispatchEvent(new Event("loaderReady"));
48 }}
49 onError={() => {
50 // A failed hero load must still release the loader overlay, or
51 // the page stays stuck behind it forever.
52 setImageLoaded(true);
53 window.dispatchEvent(new Event("loaderReady"));
54 }}
55 />
56 </div>
57 <TransitionGroup component="div" className={styles.words}>
58 {currentWords.map((word) => {
59 const nodeRef = getWordRef(word);
60 return (
61 <CSSTransition
62 key={word}
63 timeout={1000}
64 classNames={{
65 appear: styles.wordAppear,
66 appearActive: styles.wordAppearActive,
67 appearDone: styles.wordAppearDone,
68 enter: styles.wordEnter,
69 enterActive: styles.wordEnterActive,
70 enterDone: styles.wordEnterDone,
71 exit: styles.wordExit,
72 exitActive: styles.wordExitActive,
73 }}
74 appear
75 nodeRef={nodeRef}
76 >
77 <h3 className={styles.word} ref={nodeRef}>
78 {word}
79 </h3>
80 </CSSTransition>
81 );
82 })}
83 </TransitionGroup>
84 <div className={styles.hero} style={{ animationPlayState: playState }}>
85 <h1 className={styles.name}>
86 <span className={styles.firstName}>Isaac</span>
87 <span className={styles.lastName}>Bythewood</span>
88 </h1>
89 <div className={styles.role}>
90 <span className={styles.roleTitle}>Senior Solutions Architect</span>
91 <span className={styles.roleAt}>at</span>
92 <span className={styles.roleCompany}>Craftmaster Furniture</span>
93 </div>
94 <a
95 href="https://darkfurrow.com"
96 target="_blank"
97 rel="noopener noreferrer"
98 className={styles.notice}
99 >
100 <span className={styles.noticeLabel}>New Project</span>
101 <span className={styles.noticeTitle}>Dark Furrow</span>
102 <span className={styles.noticeDesc}>
103 An artistic almanac blending poetry, visual rhythm, and practical
104 wisdom for home gardeners and farmers.
105 </span>
106 </a>
107 </div>
108 </Page>
109 );
110};
111
112export default Index;