How I Built This Portfolio
A walkthrough of the architecture, design system, and tooling behind this site. Next.js 16, Tailwind CSS v4, Velite, Framer Motion, and a lot of micro-interactions.
Why I built this
Most developer portfolios get built once and abandoned. I've done it twice myself. The third time, I decided to fix the root problem: making content updates painless.
If adding a project requires editing three files and manually importing icons, you stop doing it. The goal was a site where writing a markdown file and merging a PR is the entire workflow.
This post walks through how the site works at the code level. Not a tour of features; the actual implementation decisions, the numbers, and the tradeoffs.
Stack and why each piece is there
| Layer | Tool | Why this one |
|---|---|---|
| Framework | Next.js 16 (App Router) | React Server Components, static prerendering, Turbopack dev server |
| Language | TypeScript 5 | Type safety all the way down to content schemas |
| Styling | Tailwind CSS v4 + CSS variables | Utility-first; design tokens live in CSS custom properties |
| Components | shadcn/ui | Accessible, copy-paste, no vendor lock-in |
| Content | Velite + MDX | Build-time type checking on markdown content |
| Animations | Framer Motion 12 | Spring physics, useScroll, useInView, useSpring |
| Smooth scroll | Lenis | Momentum-based scroll without breaking native behavior |
| Icons | Lucide React + React Icons | react-icons covers tech brand icons, Lucide covers UI icons |
| Validation | Zod 4 | Contact form schema validation |
| Data fetching | SWR | Client-side fetching for API widgets |
React 19 runs under the hood. Five font families load through next/font: Syne (display), Geist Sans (body), Geist Mono (code inline), Space Grotesk (UI labels), and JetBrains Mono (code blocks).
The content layer problem
I tried three approaches before landing on the current one.
First attempt: hardcoded arrays of project objects in TypeScript files. Worked fine until I had 10 projects and the file was 400 lines of data with no syntax highlighting for the descriptions.
Second attempt: a headless CMS. The roundtrip killed it. Write content in the CMS dashboard, wait for webhook, rebuild, check if the formatting survived. Too many steps.
Third attempt: Velite + MDX. This is what stuck. Velite compiles MDX at build time and generates TypeScript types from your schemas. The blog collection looks like this:
const blog = defineCollection({
name: "Post",
pattern: "blog/**/*.mdx",
schema: s.object({
slug: s.path(),
title: s.string().max(99),
excerpt: s.string().max(999),
content: s.mdx(),
publishedAt: s.isodate(),
featured: s.boolean().default(false),
tags: s.array(s.string()).default([]),
categories: s.array(s.string()).default([]),
}),
});If you misspell a field or pass the wrong type, the build fails. No silent runtime errors from bad content. The MDX layer also supports rehype-pretty-code with the github-dark theme and rehype-slug for automatic heading anchors.
Projects work the same way. The schema enforces status as an enum of "active" | "completed" | "archived" and techStack as a string array. The string array feeds into a global icon mapping (more on that later).
How the tech icon system works
Every tech stack label on the site, whether in the hero section, project cards, or experience timeline, comes from a single mapping in lib/tech-icons.ts:
export const techIcons = {
"React": { icon: SiReact, color: "#61DAFB" },
"Python": { icon: SiPython, color: "#3776AB" },
"Next.js": { icon: SiNextdotjs, color: "#ffffff" },
"Docker": { icon: SiDocker, color: "#2496ED" },
// 20+ more entries
};Project MDX files specify techStack: ["React", "Python", "Docker"] in frontmatter. The getTechIcons() helper resolves those strings into icon components and hex colors, which feed into the TechPill component.
TechPill renders each badge with hover interactions: 1.08x scale, 4px lift, 360-degree icon spin, and a color-matched blur glow behind it. The border gets a matching glow ring using inline boxShadow. All driven by Framer Motion whileHover.
Adding a new tech to the system means adding one line to the mapping file. Contributors do not need to know about the component layer.
The design system
The color palette uses six CSS custom properties:
:root {
--background: #0a0a0b;
--foreground: #fafafa;
--primary: #00ffff;
--card: #111113;
--border: rgba(255, 255, 255, 0.1);
--muted-foreground: #a1a1aa;
}Cyan (#00ffff) is the only color. Everything else is black, near-black, and gray. The constraint keeps things cohesive. Changing the accent color across the entire site means editing one hex value.
Typography scales with clamp():
.text-display {
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
line-height: 1.1;
}Four levels: .text-display for the hero name, .text-headline for section headings, .text-title for sub-headings, .text-body-lg for intro paragraphs.
Micro-interactions: the full inventory
I spent more time on micro-interactions than on any other part of the site. Here is every one, with exact implementation details.
Cursor proximity glow
The hero description text has a large (400px by 500px) blurred cyan circle behind it. This circle tracks the mouse position using Framer Motion springs.
The raw mouse position (0 to 1, normalized by viewport size) feeds into useSpring with stiffness 40 and damping 25. The spring output maps through useTransform to a range of -80px to +80px horizontally and -60px to +60px vertically. The result is a soft, lagging glow that follows your cursor without snapping.
Cursor trail
A full-viewport <canvas> element sits at z-index 9998. On every mousemove, a new particle gets pushed into a ring buffer (max 25 particles). Each frame, particles age in milliseconds. When a particle exceeds 400ms, it gets removed.
The drawing logic: alpha fades as (1 - progress)^2 for a non-linear fade-out. Radius shrinks from 3.5px to about 1.2px. Each particle gets a cyan fill and an 8px blur shadow. The effect is subtle; most visitors won't consciously notice it.
Touch devices and prefers-reduced-motion skip the effect entirely.
Velocity skew
A requestAnimationFrame loop reads window.scrollY every frame and computes velocity (current minus previous). The velocity multiplied by 0.015 gives a target skew angle. A lerp with factor 0.1 smooths it, and the value is clamped between -1.5 and +1.5 degrees. The skew applies to the <main> element via inline transform.
This comes from the Obys Agency signature. It makes fast scrolling feel physical without being distracting.
ScrollFillBlock
The about section bio text starts at 15% opacity and fills in as you scroll. Implementation uses useScroll from Framer Motion with offset ["start 0.85", "end 0.65"]. The scroll progress (0 to 1) drives a CSS mask-image gradient:
linear-gradient(to bottom, rgba(0,0,0,1) ${fill}%, rgba(0,0,0,0.15) ${fill + 12}%)
The 12% gap between fully opaque and dim creates a soft edge rather than a hard cutoff.
InlineHighlight
Key terms in the bio and hero text get three layered effects:
- An underline that sweeps from 0% to 100% width on viewport entry, using a custom cubic bezier
[0.76, 0, 0.24, 1]. - A shimmer pass 150ms after the underline finishes. A one-third-width gradient stripe animates from -33% to 133% left position.
- On hover, a spring-based 1.03x scale with a glow behind the text.
The component adds a .inline-hl class. This connects to the context dimming system.
Context dimming
When you hover any .inline-hl element, all surrounding text in the same .highlight-context container dims to rgba(255, 255, 255, 0.15). This uses the CSS :has() selector:
.highlight-context:has(.inline-hl:hover) {
color: rgba(255, 255, 255, 0.15);
}The hovered highlight keeps its own color because it sets color inline. The dimming draws your eye to the highlighted term.
ScrambleText
Section sub-labels ("Experience", "About", "Portfolio") scramble through random characters before settling into their final text. The algorithm: each character has a lock time equal to its index multiplied by 50ms. A 30ms interval runs and checks elapsed time against each character's lock time. Unlocked characters show a random pick from ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%!. Locked characters show the real letter.
ClipReveal
Section headings wipe in with a clip-path transition. The clip rectangle animates from covering 0% to 100% of the element's width, driven by useInView.
Animated gradient border
The CTA buttons have a rotating cyan arc border. This uses CSS @property to register a --border-angle custom property as an <angle> type:
@property --border-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}A conic-gradient uses --border-angle as its start angle. A border-spin keyframe rotates the angle from 0deg to 360deg over 3 seconds. The result: a single cyan highlight that sweeps around the button perimeter. Users with prefers-reduced-motion get a static cyan border instead.
3D tilt on project cards
Each project card computes rotateX and rotateY from the mouse position relative to the card center. The formula:
rotateX = ((mouseY - centerY) / centerY) * -6
rotateY = ((mouseX - centerX) / centerX) * 6
This runs on mousemove via direct DOM manipulation (not React state) to avoid re-renders. A perspective of 800px keeps the tilt subtle.
Other small things
- Availability badge:
boxShadowkeyframes cycle between 0px and 16px green glow, repeating every 2.5 seconds. - Page transitions:
AnimatePresencewithmode="wait", fade + 20px vertical slide, 400ms duration, custom ease[0.22, 1, 0.36, 1]. - MagneticButton: calculates offset from center on
mousemove, applies spring animation with stiffness 150, damping 15, mass 0.1. Default strength multiplier is 0.3. - Confetti on contact form success: 60 canvas particles with random velocity, gravity at 0.25 per frame, rotation, and alpha fade at 0.012 per frame.
- Back-to-top button: appears after 400px scroll, smooth scrolls to top.
Feature flags
All visual effects run through a featuresConfig object in config/features.config.ts. Every effect, including the cursor trail, velocity skew, scroll progress, Konami code, and API integrations, has a boolean toggle. This matters for the open-source template: users fork the repo and turn off what they don't want.
The GlobalEffects component reads these flags and conditionally renders each effect using next/dynamic for code splitting. Disabled effects ship zero JavaScript.
Easter eggs
Four hidden features:
- Konami code (up up down down left right left right B A): The component tracks the last 10 keypresses in a ref array. On match, 50 confetti particles animate with random colors and physics. The effect lasts 5 seconds.
- Matrix rain: typing "matrix" triggers a canvas-based falling-character effect.
- Logo secret: clicking the logo 5 times fast triggers a surprise.
- Console greeting:
initConsoleGreeting()runs on mount and prints a styled message to DevTools console.
API routes
Three /api routes fetch external data:
/api/github/contributions hits GitHub's GraphQL API for the contribution calendar. The query pulls contributionCalendar from contributionsCollection, which gives daily contribution counts and colors.
/api/spotify/now-playing calls the Spotify Web API. When I'm listening to something, the widget shows the track name and artist.
/api/wakatime/stats pulls weekly coding time by language.
Each route caches responses and fails silently. If an API is down, the widget hides. No error states cluttering the page.
LLMs.txt
The app/(llms)/ route group exposes structured content for AI crawlers. It includes llms.txt (a summary page), llms-full.txt (all content concatenated), and separate routes for about, experience, projects, blog, and components. The idea: if an AI indexes my site, it gets clean structured text instead of parsing HTML.
App layout and providers
The root layout.tsx wraps everything in this order:
<html>withlang="en"andclass="dark"<body>with five font CSS variables (--font-geist-sans,--font-geist-mono,--font-syne,--font-space-grotesk,--font-jetbrains-mono)SmoothScrollProvider(Lenis)GlobalEffects(cursor, trail, skew, scroll progress, easter eggs)BackToTopcomponentHeader,<main>wrapped inPageTransition,Footer
The suppressHydrationWarning on the body tag prevents React warnings from browser extensions like Grammarly that modify the DOM.
Performance
All pages prerender at build time. Build output is around 20 routes. Each is either a static page or a dynamic API endpoint.
Images go through the Next.js Image component for automatic WebP/AVIF conversion, lazy loading, and responsive srcset.
Fonts load through next/font with display: swap to prevent layout shift.
Effects that need client-side APIs (cursor, canvas, scroll) load via next/dynamic with ssr: false, so they're split into separate bundles and never block the initial render.
Project structure
portfolio/
├── app/
│ ├── (llms)/ # AI-readable content routes
│ ├── api/ # GitHub, Spotify, WakaTime endpoints
│ ├── blog/ # Blog listing + [slug]
│ ├── projects/ # Projects listing + [slug]
│ ├── components/ # UI component showcase + registry
│ └── globals.css # All CSS: variables, utilities, keyframes
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── sections/ # Hero, About, Experience, FeaturedWork, Blog, Contact, Testimonials
│ ├── shared/ # TechPill, InlineHighlight, ScrollFillBlock, ScrambleText, ClipReveal, MagneticButton, BackToTop
│ ├── effects/ # CursorTrail, CustomCursor, VelocitySkew, ScrollProgress
│ ├── easter-eggs/ # KonamiHandler, MatrixRain, LogoSecret
│ └── layout/ # Header, Footer, PageTransition
├── config/
│ ├── site.config.ts # Name, bio, stats, social links
│ ├── seo.config.ts # Meta, OG, structured data
│ ├── features.config.ts # Boolean toggles for all effects
│ └── data/ # experience.ts, skills.ts, testimonials.ts, components.ts
├── content/
│ ├── blog/ # MDX blog posts
│ └── projects/ # MDX project pages
├── lib/
│ ├── tech-icons.ts # String-to-icon + color mapping
│ ├── fonts.ts # Font configuration
│ └── utils.ts # cn(), formatDate(), etc.
└── velite.config.ts # Content schemas + rehype plugins
What I'd do differently
I over-built the animation layer early on. Several effects got cut because they felt sluggish on mid-range Android phones. The surviving ones all animate only transform and opacity, which are GPU-composited and avoid layout thrashing.
I should have started with feature flags from day one. Adding featuresConfig retroactively meant rewiring every effect through a conditional check.
The CSS :has() selector for context dimming does not work in Firefox versions before 121. I chose to ship it anyway since the fallback (no dimming) is fine. The text is still readable.
Contributing
Adding content to this site needs zero frontend knowledge.
New blog post: create content/blog/your-post.mdx with the right frontmatter, write in markdown, merge. The build validates the schema.
New project: create content/projects/your-project.mdx, fill in the frontmatter, optionally add one line to lib/tech-icons.ts if your tech is not mapped yet.
No CMS. No database. One pull request.
Coming up
- Dark/light theme toggle
- Blog search
- Newsletter signup
- More easter eggs
The codebase is open source on GitHub. Questions or suggestions: open an issue or reach out on LinkedIn.