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, useRef, useEffect } from "react";
2import styles from "@styles/pages/contact.module.css";
3import Image from "next/image";
4
5import Page from "../components/page";
6
7const CHAT_TREE = {
8 start: {
9 messages: ["Hey there!", "What brings you here?"],
10 options: [
11 { label: "I want to work together", next: "collab" },
12 { label: "Just checking out your work", next: "browsing" },
13 { label: "Job opportunity", next: "job" },
14 ],
15 },
16 collab: {
17 messages: [
18 "Nice! I'm always open to side projects and open source collabs.",
19 "Best way to reach me is email. Drop me a line and tell me what you're thinking.",
20 ],
21 options: [
22 { label: "What's your email?", next: "email" },
23 { label: "What kind of projects?", next: "projects" },
24 ],
25 },
26 browsing: {
27 messages: [
28 "Welcome! Feel free to look around.",
29 "If anything catches your eye or you want to chat, I'm easy to reach.",
30 ],
31 options: [
32 { label: "How do I reach you?", next: "email" },
33 { label: "What are you working on?", next: "projects" },
34 ],
35 },
36 job: {
37 messages: [
38 "I appreciate the interest.",
39 "I'm currently at Craftmaster Furniture as a Senior Solutions Architect and not actively looking.",
40 "That said, feel free to reach out if it's compelling.",
41 ],
42 options: [
43 { label: "What's the best way to reach you?", next: "email" },
44 { label: "What do you do there?", next: "work" },
45 ],
46 },
47 email: {
48 messages: [
49 "Email is best: [email protected]",
50 "You can also find me on GitHub as /overshard or on Discord as Overshard#4907.",
51 ],
52 options: [{ label: "Thanks!", next: "end" }],
53 },
54 projects: {
55 messages: [
56 "Lately I've been deep into AI agent workflows, automated testing infrastructure, and tooling for fast release cycles.",
57 "On the side I build self-hosted tools and experiment with whatever is new. Check out the Code page for more.",
58 ],
59 options: [
60 { label: "How do I reach you?", next: "email" },
61 { label: "Cool, thanks!", next: "end" },
62 ],
63 },
64 work: {
65 messages: [
66 "I focus on AI agent workflows, automated integration testing, and building systems for rapid releases without sacrificing stability or security.",
67 "Two decades of experience across the full stack, from kernel modules to regulated healthcare environments.",
68 ],
69 options: [
70 { label: "How do I reach you?", next: "email" },
71 { label: "Interesting, thanks!", next: "end" },
72 ],
73 },
74 end: {
75 messages: ["Anytime! Take care."],
76 options: [],
77 },
78};
79
80const Contact = () => {
81 const [chatHistory, setChatHistory] = useState([]);
82 const [currentNode, setCurrentNode] = useState(null);
83 const [typing, setTyping] = useState(false);
84 const [messageQueue, setMessageQueue] = useState([]);
85
86 useEffect(() => {
87 // Start the chat after a brief delay
88 const timer = setTimeout(() => {
89 queueMessages("start");
90 }, 800);
91 return () => clearTimeout(timer);
92 }, []);
93
94 const processingRef = useRef(false);
95
96 useEffect(() => {
97 if (messageQueue.length === 0 || processingRef.current) return;
98 processingRef.current = true;
99 setTyping(true);
100 const delay = 600 + messageQueue[0].length * 15;
101 const timer = setTimeout(() => {
102 setChatHistory((prev) => [
103 ...prev,
104 { type: "isaac", text: messageQueue[0] },
105 ]);
106 setMessageQueue((prev) => prev.slice(1));
107 setTyping(false);
108 processingRef.current = false;
109 }, delay);
110 return () => clearTimeout(timer);
111 }, [messageQueue]);
112
113 const chatContainerRef = useRef(null);
114
115 useEffect(() => {
116 if (chatContainerRef.current) {
117 chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
118 }
119 }, [chatHistory, typing]);
120
121 const queueMessages = (nodeKey) => {
122 const node = CHAT_TREE[nodeKey];
123 setCurrentNode(nodeKey);
124 setMessageQueue(node.messages);
125 };
126
127 const handleOption = (option) => {
128 setChatHistory((prev) => [
129 ...prev,
130 { type: "user", text: option.label },
131 ]);
132 if (option.next) {
133 setTimeout(() => queueMessages(option.next), 400);
134 }
135 };
136
137 const node = CHAT_TREE[currentNode];
138 const showOptions =
139 node &&
140 node.options.length > 0 &&
141 messageQueue.length === 0 &&
142 !typing &&
143 chatHistory.length > 0 &&
144 chatHistory[chatHistory.length - 1].type === "isaac";
145
146 return (
147 <Page
148 title="Contact"
149 description="How to get in contact with me."
150 gridArea="1 / 1 / 4 / 7"
151 >
152 <div className={styles.background} />
153 <div className={styles.grid}>
154 <div className={styles.gridLeft}>
155 <div className={styles.contactWrapper}>
156 <h1 className={styles.heading}>Contact Me</h1>
157 <h2 className={styles.shoutOut}>Shoot me a message.</h2>
158 <h2 className={styles.shoutOut}>
159 If it's interesting, I'll respond.
160 </h2>
161 <dl className={styles.contactList}>
162 <dt className={styles.contactKey}>Email</dt>
163 <dd className={styles.contactValue}>
164 <a
165 className={styles.contactLink}
166 href="mailto:[email protected]"
167 >
168 isaac@bythewood.me
169 </a>
170 </dd>
171 <dt className={styles.contactKey}>LinkedIn</dt>
172 <dd className={styles.contactValue}>
173 <a
174 className={styles.contactLink}
175 href="https://www.linkedin.com/in/ibythewood/"
176 rel="noopener noreferrer"
177 target="_blank"
178 >
179 Isaac Bythewood
180 </a>
181 </dd>
182 <dt className={styles.contactKey}>GitHub</dt>
183 <dd className={styles.contactValue}>
184 <a
185 className={styles.contactLink}
186 href="https://github.com/overshard"
187 rel="noopener noreferrer"
188 target="_blank"
189 >
190 /overshard
191 </a>
192 </dd>
193 <dt className={styles.contactKey}>Discord</dt>
194 <dd className={styles.contactValue}>
195 <a
196 className={styles.contactLink}
197 href="https://discordapp.com/"
198 rel="noopener noreferrer"
199 target="_blank"
200 >
201 Overshard#4907
202 </a>
203 </dd>
204 </dl>
205 </div>
206 </div>
207 <div className={styles.gridRight} ref={chatContainerRef}>
208 <div className={styles.chat}>
209 {chatHistory.map((msg, i) => (
210 <div
211 key={i}
212 className={`${styles.chatLine} ${
213 msg.type === "user" ? styles.chatLineUser : ""
214 }`}
215 >
216 {msg.type === "isaac" && (
217 <span className={styles.chatAvatar}>
218 <Image
219 src="/static/images/avatar.webp"
220 alt="Isaac"
221 width={40}
222 height={40}
223 />
224 </span>
225 )}
226 <div
227 className={`${styles.chatBubble} ${
228 msg.type === "user" ? styles.chatBubbleUser : ""
229 }`}
230 >
231 {msg.text}
232 {msg.type === "isaac" && <span>Isaac</span>}
233 </div>
234 </div>
235 ))}
236 {typing && (
237 <div className={styles.chatLine}>
238 <span className={styles.chatAvatar}>
239 <Image
240 src="/static/images/avatar.webp"
241 alt="Isaac"
242 width={40}
243 height={40}
244 />
245 </span>
246 <div className={styles.chatBubble}>
247 <span className={styles.typingDots}>
248 <span />
249 <span />
250 <span />
251 </span>
252 </div>
253 </div>
254 )}
255 {showOptions && (
256 <div className={styles.chatOptions}>
257 {node.options.map((opt, i) => (
258 <button
259 key={i}
260 className={styles.chatOption}
261 onClick={() => handleOption(opt)}
262 >
263 {opt.label}
264 </button>
265 ))}
266 </div>
267 )}
268
269 </div>
270 </div>
271 </div>
272 </Page>
273 );
274};
275
276export default Contact;