Personal Portfolio Site

Last updated on

Source code

Don't worry, images are coming

I recently came back to web development after a short break, so I wanted this portfolio project to feel like a reset - something built with a simple stack and no unnecessary abstractions. I stuck to React, Next.js, and CSS Modules, avoiding Tailwind, TypeScript, and other utilities so I could get back to basics.

The result is a small project that helped me shake off the rust and rebuild some fluency. So far, I've run into a handful of genuinely interesting problems - things that pushed me to revisit mental models, rethink component boundaries, and get comfortable writing CSS again.

Below are the highlights. Each includes a short summary, plus an optional deep dive if you want the details.

A flexible three-column layout using CSS Grid

The layout uses a simple 3-column grid that lets most content sit naturally in the central column while certain sections expand outward for greater prominence. Because the side columns scale smoothly around breakpoints, the page feels consistent without having to rely on precise media queries.

The grid layout system was borne from wanting to do slightly fancier things with the layout. In particular, I wanted to have sections of the page break out and pull wider to help give them more prominence on wider viewports (rather than there being a lot of empty extra space on the sides).

Initially I was just using a simple centred column, but then eventually switched to using a 3-column grid for each page, and it looks something like this:

.layout {
display: grid;
grid-template-columns:
1fr
min(var(--trimmed-content-width), 100%)
1fr;
max-width: var(--outer-content-width);
margin: 0 auto;
padding-inline: var(--viewport-padding);
}

The central column (which remains the main content column) has a minimum width of either 100% or something defined (--trimmed-content-width). This means on smaller viewports, this column's width is 100% while the side columns will be zero width. But as the viewport grows beyond trimmed-content-width, the side columns will equally share the available space until that maximum width is hit.

So say you have a section spanning all 3 columns - on smaller viewports it will appear like the rest of the content as the side columns have zero width. But as the viewport increases, these sections will smoothly grow in size.

This made things so much easier than what I was trying to do when I had just the single column, where I was using negative margins and having to be really precise around breakpoints to help minimise "jerky jumps".

A scalable Card component system

A base-level, generic Card component is used by a group of different cards, acting as a central area for shared logic and sytles, and making it easy to introduce new "variant" card components easily.

One of the things I like about the component-driven architecture of React is that it lends itself to easily composing components based off other components. In a well-designed React app, I believe there is a spectrum of components, where on one side you have your low-level, generic components, and then as you move along the spectrum, the components become more complex or baked-in until you get to the entire app.

At one point in developing this site, I had 3 different card components, and so this made me think about the spectrum and how I could refine my design. It made sense to extract a low-level, generic Card, from which the other three cards could become variants thereof.

A tricky part was figuring out how to configure the correct API so that a variant card could override the base card styles. For example, if I wanted my card to have 8px of border-radius instead of the default 12px, how should that work?

Initially, I thought the order mattered and so including the more specific styles after the base styles would solve this. But it didn't, presumably down to how CSS modules are compiled in React, I'm guessing. At any rate, a solution I found was to expose CSS properties in the base card to accept CSS variables as values. Then in the variant cards I could modify these CSS variables to help achieve a certain look.

This approach required adjusting my mental model for dealing with such abstraction of components when using CSS modules: the base component defines the API via CSS variables; variants provide the values.

A smooth, scroll-reactive header

A header that hides as you scroll down, reveals as you scroll up - a challenge that requires giving more thought to potential performance issues, like minimising hammering React with lots of re-renders, potentially causing "jerky" UI effects.

A cool thing I've noticed on other sites is the 'hiding header' - a header that will hide on scroll down, reappear on scroll up. I wanted the same behaviour here, and it turned out to the trickier than I expected.

My first attempt involved writing a custom hook:

function useScrollDirection({ initialDirection = 'up' }) {
const [scrollDir, setScrollDir] = React.useState(initialDirection);
const [scrollY, setScrollY] = React.useState(0);

const threshold = 10;

React.useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY;

if (currentScrollY > scrollY && currentScrollY > threshold) {
setScrollDir('down');
} else if (currentScrollY < scrollY) {
setScrollDir('up');
}

setScrollY(currentScrollY);
};

window.addEventListener('scroll', handleScroll, {
passive: true,
});

return () => window.removeEventListener('scroll', handleScroll);
}, [scrollY]);

return scrollDir;
}

This works - but it's not ideal. Because scrollY changes constantly, the effect runs constantly too. The event listener is torn down and re-attached every time, and the component re-renders on every single scroll event. Furthermore, scroll events could fire numerous times per second, so this approach doesn't scale well.

We can begin to fix this by firstly using refs to track scroll position instead of state.

const lastScrollY = React.useRef(0);

Refs persist across renders without causing re-renders, so this was ideal here as I could use this in the effect and avoid having to include state in the dependency array. This means the effect only runs once and the event listener is only attached once.

To prevent React state updating too often, we can wrap the updating logic with requestAnimationFrame (RAF). Basically using this helps to throttle state events - so say if React wants to fire off 50 state changes in a millisecond, then RAF helps to schedule a state change only once every frame change (roughly once every 16ms). This helps to prevent our app from getting hammered with state changes and visually we don't notice any difference (we don't get any weird glitchy side-effects either).

Here's the cleaned up version of the hook:

function useScrollDirection({ initialDirection = 'up' }) {
const [scrollDir, setScrollDir] = React.useState(initialDirection);

const lastScrollY = React.useRef(0);
const ticking = React.useRef(false);
const threshold = 10;

React.useEffect(() => {
const update = () => {
const current = window.scrollY;

if (Math.abs(current - lastScrollY.current) >= threshold) {
const newDir = current > lastScrollY.current ? 'down' : 'up';

setScrollDir((prev) => (prev !== newDir ? newDir : prev));

lastScrollY.current = Math.max(current, 0);
}

ticking.current = false;
};

const onScroll = () => {
if (!ticking.current) {
requestAnimationFrame(update);
ticking.current = true;
}
};

window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);

return scrollDir;
}

Note: the ticking ref helps to schedule the RAF updates. We use a ref here so that its value persists during re-renders, which is what we want.

The end result is we get a smooth animation when the header hides or reappears, our app isn't doing unnecessary state-changing work, and the scroll listener never gets re-created.

A reusable Carousel with no z-index hacks

A scrollable carousel containing overlapping layers - track, items, fades, buttons. Creates a stacking context so everything layers correctly without getting engaged in "z-index wars".

Carousels have a lot of overlapping elements:

  • the scrollable track,
  • the fading edge masks,
  • the left/right buttons,
  • and the cards inside the track.

Instead of trying to coordinate these layers with different z-index numbers, I used a different approach. I created a new stacking context and I did it with just one line on the parent element:

.trackContainer {
isolation: isolate;
}

Doing this helps to isolate the carousel so all stacking happens within it, and there's no intereference from elsewhere in the app.

Once that's in place (and all our layers opt into the positioned layout), stacking can be controlled entirely by DOM order:

<div className="{styles.trackContainer}">
{/* Base layer */}
<div className="{styles.track}">{/* cards */}</div>

{/* Fade layers (higher because they come later in the DOM) */}
<div className="{styles.fadeLeft}" />
<div className="{styles.fadeRight}" />

{/* Buttons (highest because they're last) */}
<button className="{styles.leftButton}">…</button>
<button className="{styles.rightButton}">…</button>
</div>

No z-index values, no clashes with other parts of the app.

It's a simple technique, but it makes the component so much easier to reuse and navigate, which is so important in a component-driven framework like React.

A client-side contact form with incremental Zod validation

The form validates on submit first, then only the fields that failed begin validating as the user edits them, keeping feedback relevant without being noisy.

The contact form started off with using a server action in its workflow, mostly because I figured it made sense seeing as how I was using Next.js. The action validated inputs with Zod, submitted the request to Web3Forms, and returned structured state back to the client. The returned state looked something like this:

stateStructure = {
status: 'idle', // idle || success || error
message: '',
errors: {},
values: {},
};

Carrying both errors and values made it easy to preserve input values and attach validation messages back to the client after the server action had finished.

This approach worked well until Web3Forms began blocking server-action requests. A blog post of theirs - ironically titled "Create a Contact Form with Next.js Server Actions" - now explicitly recommends not doing that and instead doing everything in the client.

The refactor was straightforward, but it changed how I approached the feature. With everything running in the client, I naturally shifted back into a more "React-like" mental model.

// The new raft of state, cleanly separated
const [formData, setFormData] = React.useState(initialFormData);
const [status, setStatus] = React.useState('idle'); // idle || loading || success || error
const [errors, setErrors] = React.useState({});
const [touched, setTouched] = React.useState([]);
const [feedback, setFeedback] = React.useState('');

I started marking fields as touched, validating individual rules as users typed after an initial failed submission, thinking more about improving the UI experience.

And here's the interesting part: I probably could have implemented these patterns even with server actions in play. Maybe the mental overhead of mixing server and client components around handling form submissions made me hesitate.

Once everything moved client-side, that mental fog dissipated and the interaction design improved immediately. And it was probably the most excited I felt while building this app.

It made me realise that thinking too hard about React Server Components optimisations early on can stifle creativity. Or maybe in an app such as this, where there aren't too many database interactions or opportunities to pick up these small RSC-style efficiencies, then it's better to just take more of a client-first approach.

I wanted to build a section of the page that expanded when clicked. My first instinct was to do it the "React way". I had state tracking whether the user wanted the section opened or not and what the necessary height of the section needed to be to show all of it. An effect would fire every time the user opens the section, calculate and update the height state, and this in turn would be used in a CSS class, making it appear visible.

It worked but later I noticed that if I resized the window while it was open, the section wouldn't naturally adjust because I had nothing to cater for this scenario. When the logical answer was to add a resize event listener, which would have required another effect, that's when the alarm bells started to go off and I began investigating other potential approaches.

As it turned out, CSS Grid has a simple way to make this accordion-like effect really simple.

The section becomes a one-row grid whose size is dependent on whether it's open. The collapsed state looks like this:

grid-template-rows: 0fr;

And the expanded section becomes:

grid-template-rows: 1fr;

Add a transition and that's basically the accordion effect achieved.

So instead of having all that height measuring logic involving multiple state and effects, we only need to use React for the toggle and then CSS, and CSS Grid in particular, can handle everything else. Pretty cool, and a useful reminder of how darn helpful CSS can be to help solve challenges like this.