Case study · Frontend
aun.sh
This site, explained by this site.
- Role
- Designed and built it, rewrites included
- Stack
- Next.js, React, Tailwind CSS, WebGL2, GSAP
- Year
- 2026
- Contours
- Made up. The projects are real

Summary
A portfolio is usually a list. I wanted a place: the projects stand on a 3D landscape, my jobs run across it as a trail, and the loading screen is a survey of the same land drawn from above. No 3D library, no stock art, and it still loads like a page.
120KB
first-load JavaScript on the home page
0.002
layout shift on a cold load
0
3D libraries. The terrain is raw WebGL2
1
noise function behind the hero, the loader, the pins and the trail
My part
What I made
All of it, design to deploy. The hero alone went through more drafts than I'll admit to.
- Terrain
- A WebGL2 landscape: a grid lifted by simplex noise, contour lines drawn from height, and a hill that rises under the pointer.
- Pins and trail
- Projects as pins and jobs as stops, kept on the ground by the camera. The trail is an SVG switchback cast onto the land, so it rides the hills.
- Side projects
- Smaller work as quiet dots between the pins, each with a tooltip. The cursor says Go over anything you can open.
- Logo
- An Archivo "a" with an orange pin on its shoulder: the favicon, the app icons, and the header, where the name folds into it.
- Loading screen
- A survey map of the same land, an SVG animated with CSS, that tips back into the 3D hero.
- Motion
- Smooth scrolling, headings that rise line by line, stacking project cards and a custom cursor, all skipped under reduced motion.
- Case studies
- These pages, with captures and numbers measured from the real products.
- Search and sharing
- Structured data for the person, the site and each case study, plus a share card drawn as a miniature of the hero.
- Words
- The copy and the jokes. The contour lines are 10 m of scope creep each.
01 · Loading screen
A survey before the landscape
On the first visit, the land is drawn as a contour map: lines rise from the valleys, the career trail walks through each job, the projects get plotted, then the sheet tips back into the 3D hero.


02 · Hero
The work, standing on the land
Each project is a pin, each side project a small dot, and each job a stop on a trail that switchbacks up the land, newest first. Move the pointer and a hill rises under it, its contours turning orange; hover a pin and the cursor says Go.



03 · The rest
Then it's a fast, quiet page



04 · Mobile
Same map at 390 pixels
The terrain takes the top of the screen and the legend moves below it. Pins pull toward the middle to stay on screen, job labels and side-project dots drop off, and the loader leaves out the outer project pins.




05 · Design system
Warm paper, near-black ink, international orange
- Paper#F3F1EC
- Surface#FBFAF7
- Ink#111111
- Muted#4A4741
- Faint#67635B
- Orange#FF4F00
- Orange ink#C43C00
Archivo
LET'S TALK
Statements and the big words, with weight and width that bend
Instrument Serif
Also, yes, the CSS.
Asides and jokes, always italic
Inter
Each line is 10 m of scope creep
Everything you read for longer than a line
A loading counter with no JavaScript
@property --n {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
.preloader-n {
counter-reset: n var(--n);
animation: pre-count 1.75s forwards;
}
.preloader-n::after { content: counter(n); }
@keyframes pre-count { to { --n: 100; } }- Orange is for things that respond to you: pins, the pointer's hill, the Go cursor, links on hover. And the dot on the logo.
- Light only. The paper is part of the map.
- A typed integer property animates from 0 to 100 and a CSS counter prints it, so the loader counts before the page's JavaScript arrives.
06 · Tech
Static pages, a GPU and very little JavaScript
1 · Build
Next.js App Router
Every page is static. Fonts come through next/font, the case studies come from one content file, and each page carries JSON-LD and a share card drawn with next/og.
2 · First paint
CSS only
The loader, its counter and the intro fades run before the page's JavaScript loads, and only on the first visit of a session.
3 · Hero
Raw WebGL2
One shader pair draws the land. Pins, dots and the trail are HTML and SVG on top, placed each frame with a CPU copy of the same noise.
4 · Motion
GSAP and Lenis, later
Loaded after hydration, so they never hold up the first paint, and their effects are skipped under reduced motion.
One noise, four uses
The shader, the pin placement, the trail and the loader's contour map share one simplex noise, so the map you see first is the land you arrive on.
Keep the GPU cheap
The canvas pauses offscreen and in background tabs, caps pixel density and draws a lighter mesh on phones.
An image, not markup
The loader's 142 contour lines ship as one animated SVG file, which keeps them out of the HTML and the hydration payload.
The trail left the shader
Drawn in WebGL it broke wherever a ridge hid it. Now it's laid out once per resize as a screen-space switchback, each point cast down onto the land, then projected every frame as a plain SVG path, so it stays whole and sharp.
- Next.js 15
- React 19
- TypeScript
- Tailwind CSS 4
- WebGL2
- GLSL
- SVG
- GSAP
- Lenis
- next/font
- next/og
- JSON-LD
- d3-contour
- Netlify
07 · Code
Contour lines from height, in the shader
There's no line geometry. Each pixel checks how close its height is to a contour level and inks itself when it's close. fwidth keeps lines the same width at any distance, every fifth line is thicker, the light is worked out per pixel so no mesh triangles show, and the land fades into haze far away.
The fragment shader's main function, unedited.
void main() {
// Light per pixel, not per mesh corner: shading interpolated between
// corners picks out the triangles where the land is close.
float e = 0.06;
vec3 n = normalize(vec3(relief(vP - vec2(e, 0.0)) - relief(vP + vec2(e, 0.0)), 2.0 * e, relief(vP - vec2(0.0, e)) - relief(vP + vec2(0.0, e))));
float shade = dot(n, normalize(vec3(-0.5, 0.75, 0.45)));
float v = vH * 9.0;
float f = fract(v);
float w = fwidth(v);
float band = floor(v + 0.5);
bool index = mod(band, 5.0) == 0.0;
float line = 1.0 - smoothstep(0.0, w * (index ? 1.7 : 1.0), min(f, 1.0 - f));
float haze = clamp(1.0 - (vDepth - 3.0) / 12.0, 0.0, 1.0);
vec3 ground = uBg * (0.9 + 0.1 * clamp(shade, 0.0, 1.0));
vec3 ink = mix(uInk, uAccent, clamp(vNear * 1.4, 0.0, 1.0));
float a = line * mix(index ? 0.5 : 0.3, 0.95, clamp(vNear, 0.0, 1.0));
vec3 col = mix(ground, ink, a);
color = vec4(mix(uBg, col, haze), 1.0);
}08 · Performance
Measured on a production build
A cold load of the home page with the loading screen, served by next start on November 2, 2026 and read from the browser's Performance API.
Home page
next start · gzip- JavaScript in 13 files, motion libraries included
182KB
- CSS
14KB
- fonts, four files
170KB
- the loader's animated map
26KB
- requests
28
- cumulative layout shift
0.002
09 · Next
What I'd ship next
- 01Keyboard access to the terrain: arrow keys to survey, not just the pointer.
- 02Pin cards on touch screens, which only get the labels today.
- 03Lighthouse in CI with budgets, so the 3D hero can't quietly get heavier.