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
aun.sh
The aun.sh hero: a 3D contour landscape with four project pins, small side-project dots, the career trail through five jobs, and the legend card

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.

aun.sh
The loading screen at the end: a contour map with the dashed career trail through UC Davis, mlpal.ai, Prosperix, 2WheelR and Teach for India, four orange project pins and a counter at 100%
Plotted. The map is generated from the same noise as the 3D terrain, so the pins land where the hero puts them.
The loading screen mid-way: contour lines drawn, the trail through its first two jobs, the counter at 54%
Mid-survey. The counter and the status line are CSS only, so they run before the page's JavaScript loads.

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.

aun.sh
The hero with an orange-contoured hill raised under the pointer between the FRED and reaktr pins, and the survey readout showing coordinates and elevation
Survey mode. The readout reports where the pointer is and how high, in made-up Bay Area coordinates.
Hovering the FRED pin: an orange Go cursor and a preview card with a screenshot, a one-line description and a link to the case study
Pin cards open beside the pin, toward the middle of the screen, and the cursor turns orange and says Go.
Selected work: a full-width FRED card in its own dark green with a count-up metric and a large screenshot
Below the fold, each project card wears the product's own colors and stacks under the next.

03 · The rest

Then it's a fast, quiet page

aun.sh/#experience
The fine print: experience, writing, papers, education and tools in three columns
The fine print. Everything a recruiter scans for, in three columns.
Footer: Looking for a full-stack or applied AI role in serif italic, and a huge LET'S TALK in Archivo
The footer's LET'S TALK bends its weight and width toward the pointer.
The FRED case study page cover on the same paper, ink and type as the home page
Case studies share the paper, ink and Inter, so the site reads as one piece.

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.

Phone loading screen: the contour map cropped to its middle, with the trail and its five jobs
Loader
Phone hero: the terrain with four pins and the trail on top, and the legend card below
Hero
Phone work section: the FRED card stacked full width
Work
Phone menu open: Work, the five case studies, Experience, Writing, Contact and Résumé
Menu

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. 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. 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. 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. 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

  1. 01Keyboard access to the terrain: arrow keys to survey, not just the pointer.
  2. 02Pin cards on touch screens, which only get the labels today.
  3. 03Lighthouse in CI with budgets, so the 3D hero can't quietly get heavier.