Personal portfolio — Next.js 16 (App Router) + React 19 + Tailwind CSS v4, TypeScript, deployable to Vercel.
npm install
npm run dev # http://localhost:3000
npm run build # production buildAll copy, links, projects, services, stack and stats live in src/content/site.ts.
Anything marked // TODO is a placeholder — swap it before shipping. No other file
needs touching for a content change.
src/app/layout.tsx fonts + metadata (title, OG, Twitter)
src/app/globals.css theme tokens, marquee/reveal/caret animations
src/app/page.tsx section order
src/components/ Nav, Hero, Marquee, About, Featured, Services,
Stack, Writing, Contact, Footer, Cursor, Reveal
node scripts/screenshot.mjs http://localhost:3000 homeScrolls the page in steps, screenshots each one into screenshots/ (gitignored)
and prints any console errors — the scroll-driven animations are hard to verify
any other way. Needs npx playwright install chromium once.
Dark is the default and the design's home; light is an explicit opt-in, not a
system guess. Two independent axes, both stored on <html>:
data-mode—dark|light(surfaces, borders, effect strengths)data-theme—blue|ember|matrix|violet|amber|rose(accent trio)
Any accent works in either mode. The picker lives in the nav; the choice is saved to
localStorage and replayed by an inline script in <head> before first paint, so
there is no flash of the default theme. <html data-*> is the single source of
truth — components read it through useSyncExternalStore, never a mirrored state.
Add a palette by appending to THEMES in src/lib/theme.ts and adding a matching
[data-theme="…"] block in globals.css.
Ported from the reference's own bundles (I read its minified components), using
the same libraries it does — motion for spring/scroll work, lenis for
smooth scrolling. The numbers below are the ones it actually runs.
| Effect | Where | Detail |
|---|---|---|
| Section-outline particle field | fx/EdgeField |
6000 particles trace the perimeter of the section you're on and morph to the next, staggered per particle; ring for the hero (sized to enclose the name). A section can nominate an inner element with data-field-frame and the outline wraps that instead — the contact card uses it — or pin it with data-field-hold (the services deck does both, so the outline holds still until every card has passed), then breaks into two side clusters over the footer. The box follows the section as you scroll — that tracking is most of the effect. Pointer repulsion with swirl, click shockwaves, scroll-speed energy driving size/opacity/jitter. Canvas 2D, additive |
| Intro preloader, 0→100 | fx/Preloader |
900ms easeOutQuad, once per session, freezes scroll via is-loading |
| Cursor ring + dot | fx/CursorRing |
exponential smoothing (dot k=30, ring k=12, scale k=16); ring ×1.8 over links, dot ×0.5 on press |
| Pointer glow + grid reveal | fx/CursorFX |
--mx/--my on <html>, rAF-throttled |
| Hero pointer parallax | Hero |
whole block on ±10/8px springs (60/18) |
| Smooth scrolling | fx/SmoothScroll |
Lenis, held stopped until the intro clears |
| Scroll progress hairline | fx/ScrollProgress |
spring 120/30/0.3, 3px with glow |
| Page tint drifting per section | fx/SectionTint |
colours in tints (site.ts) |
| Word-by-word masked headings | fx/AnimatedHeading |
y 110%→0, 0.06s stagger, accent words in gradient |
| Glyph scramble | fx/ScrambleText |
700ms; terminal lines cascade at 140ms each |
| Auto-firing glitch | fx/Glitch |
hover, plus a 280ms burst every 4–8s on its own |
| Velocity marquee | Marquee |
base 3.33%/s, boosted/reversed by scroll velocity, skewX ±4° |
| Scroll-driven 3D card deck | Services |
260vh sticky stage, 1400px perspective, progress dots |
| Card spotlight border + fill | fx/Spotlight |
--gx/--gy per card |
| Directional scroll reveals | fx/Reveal |
0.7s, cubic-bezier(.22,1,.36,1) |
| 3D tilt card | fx/TiltCard |
About photo: pointer-tracked rotateX/rotateY on 150/18 springs, children layered on the Z axis (bloom −60px, outline +45px, photo +20px, label +70px), glare tracking the cursor |
| Magnetic buttons | fx/Magnetic |
|
| Stat count-up | fx/CountUp |
easeOutExpo on first view |
| Role typewriter + block caret | fx/Typewriter |
|
| Copy-email confirmation, animated mobile drawer, social lift-and-glow, floating orbs, scan lines, pulse dot, link wipes | Contact, Nav, SocialLinks, globals.css |
All of it collapses to static under prefers-reduced-motion: reduce — the card
deck falls back to a plain grid — and the pointer-driven layers are dropped on
coarse pointers and small screens.
Mirrors the reference exactly:
hero → marquee → about (01) → services (02) → stack (03)
→ work (04) → writing (unnumbered) → contact (05)
Nav links follow the same order, with Blog last. Stats live at the foot of About, not in the hero — the hero carries the badge, name, one paragraph, two buttons and the social row, and nothing else.
/ and /blog. Both wrap in SiteChrome, so the ambient layers, cursor,
smooth scroll, nav and footer are identical across routes; nav anchors rewrite
to /#section when you are off the home page.
Push to GitHub and import on Vercel — zero config.