Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions content/stripping-it-back.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
title: "Why I Rebuilt My Portfolio Around the Vercel Aesthetic"
publishedAt: "2026-05-03"
updatedAt: "2026-05-03"
author: "Tyler H"
summary: "A look at the decisions behind my latest portfolio redesign removing the visual noise, leaning into Tailwind v4, and why 'boring' design is often the most honest kind."
image: ""
---

I rebuilt my portfolio today. Not because it was broken it worked fine but because every time I looked at it, something felt off. It wasn't one thing. It was the accumulation of small decisions made over months of iteration that had slowly added up to a design that felt cluttered, compensating, and tired.

So I stripped it back. Here's why, and what I actually changed.

### The Problem With "Impressive" Design

There's a trap that most developers fall into when they build their own portfolio. It's the same trap I fell into. You want it to *look* like you know what you're doing, so you reach for motion, glow effects, aggressive gradients, and glass morphism whatever is trending on Dribbble that month. The result is a portfolio that screams "I can copy a tutorial" more than it demonstrates actual craft.

My old dark mode was `oklch(0.18)`. That's not dark. That's "we're scared of the dark." Genuine dark mode backgrounds should make your monitor feel like it's off when you're not looking at it. Vercel ships at near-true black `oklch(0.08)` and it's one of the first things that jumps out when you land on their site. The content doesn't fight the background for attention. The background just... disappears.

That's the whole game.

### What "Vercel Aesthetic" Actually Means

People throw this phrase around a lot, but very few actually break down what makes it work. It's not just "dark background, white text." It's a system of deliberate restraint:

- **No glow shadows.** The old version had `shadow-[0_0_10px_3px] shadow-primary/5` on basically everything. It's subtle, but it adds up to a constant low-level visual hum that tires the eye.
- **Flat surfaces.** Cards shouldn't float. `backdrop-blur-3xl` + `bg-card/90` is a hack that makes a flat element look like a depth illusion. Real depth comes from border contrast, not blur.
- **Tight radius.** I dropped from `0.625rem` to `0.5rem` on the border radius. It sounds pointless. The difference is immediately visible everything snaps from "friendly SaaS" to "professional tooling."
- **Honest borders.** Light mode borders at `oklch(0.88)` instead of the near-invisible `oklch(0.922)`. You should be able to see the edge of a card. It's not a mystery box.

The Vercel design language works because it communicates *confidence*. It doesn't need to impress you. It assumes you're already paying attention.

### Tailwind v4 Gotchas I Hit Along the Way

Since the redesign was built in **Tailwind v4**, a few things bit me that are worth documenting.

**Arbitrary values have new shorthand.** `gap-[3px]` is now `gap-0.75`. `min-h-[72px]` is `min-h-18`. The compiler will flag these, but only at runtime in Next.js, not at write time unless you have the right LSP setup. Expect a wave of lint errors on first load if you're migrating.

**`@layer utilities` doesn't register animation classes the way v3 did.** I spent longer than I should admit trying to get a custom marquee component to scroll. The fix was bypassing Tailwind's class generation entirely for the animation property define your `@keyframes` in the CSS, then apply them via inline `style` props in the component. Let the browser handle it directly.

```css
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
```

```tsx
style={{ animation: `marquee ${duration}s linear infinite` }}
```

Not elegant, but it works every time. Sometimes the right answer is to stop fighting the abstraction.

**`[mask-image:...]` arbitrary properties use a different syntax now.** The correct form in v4 is `mask-[linear-gradient(...)]`, not `[mask-image:linear-gradient(...)]`. Small thing, but it'll silently break your fade-edge effects if you miss it.

### The Skills Section Problem

One of the bigger structural changes was the skills section. The original version was a flat list of ~20 technology pills. Fine for 20. Absolutely unusable when you have 80+ skills across 9 categories.

I went through a few iterations:

1. **Two-row marquee.** Looked dynamic, but with 80 items the rows move too fast to read, and slow enough to read means it takes forever to see everything. Dropped.
2. **Categorized grid.** Technically correct, visually overwhelming. Four columns of dense pills with small category headers is just a wall.
3. **Tabbed interface with level indicators.** This is what I shipped.

The final version uses category tabs Frontend, Backend, Database, DevOps, etc. and inside each tab, each skill pill has five dot indicators showing proficiency level. Hover any pill and a tooltip confirms the label: *Beginner* through *Expert*. It's clean, scannable, and actually communicates something useful rather than just listing every technology I've ever touched.

The dots are dead simple five `size-1 rounded-full` spans, filled vs unfilled. No libraries, no charts, no radial progress rings. The simplest solution is usually the right one.

### The Part Nobody Talks About

Rebuilding your own portfolio is a weird experience. There's no client, no deadline, and no brief. You're the worst client you'll ever work with because you have opinions about everything and the scope creeps every five minutes.

But there's something honest about it too. The decisions you make on your own project when nobody is watching and nobody is paying you are the most direct reflection of what you actually believe. The temptation to add another animation or another frosted glass panel is real. Every time I felt that pull, I asked: *does Vercel ship this?*

Nine times out of ten, the answer is no, and I deleted it.

That's the discipline. Not knowing how to build impressive things, but knowing when not to.

### Rethinking the Projects Section

The project cards section got a full pass too. The original was a 2-column grid of cards, each with a `h-48` screenshot, a description block, and a pile of tech stack badges. Fine as a starting point, but it scaled badly. Four cards is manageable. Ten cards is a scrollathon.

The solution was a hybrid layout: the two most important projects get featured cards at the top (same 2-col grid, but tightened up `h-36` thumbnails, `p-4` padding, tags capped at 4 with a `+N` overflow counter), and everything else falls into a compact list below. Each list row is just title, date, a few tag chips, and an arrow. No images, no descriptions. The information density is completely different without feeling like a downgrade.

For the link badges that used to float over the screenshot, I dropped the solid black `Badge` and replaced them with `bg-background/90 border border-border` backed pills with `backdrop-blur-sm`. They match the surface language of the rest of the page instead of looking like an afterthought.

**The "show more" pattern** was the final piece. Rather than dumping all 12 projects on the page at once, the list starts at 2 visible rows. A `Show N more` button (just a `text-xs text-muted-foreground` label with a `ChevronDown`, no border, no box) reveals 2 more at a time. Once expanded, a `Show less` appears alongside it. Both buttons are inline, separated by a `·` dot. They disappear entirely when you're at the default state. It's the Vercel pattern: progressive disclosure, no clutter until you ask for it.

### The Part Nobody Talks About

Rebuilding your own portfolio is a weird experience. There's no client, no deadline, and no brief. You're the worst client you'll ever work with because you have opinions about everything and the scope creeps every five minutes.

But there's something honest about it too. The decisions you make on your own project when nobody is watching and nobody is paying you are the most direct reflection of what you actually believe. The temptation to add another animation or another frosted glass panel is real. Every time I felt that pull, I asked: *does Vercel ship this?*

Nine times out of ten, the answer is no, and I deleted it.

That's the discipline. Not knowing how to build impressive things, but knowing when not to.

Stay caffeinated. Stay real.
4 changes: 2 additions & 2 deletions src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ export default async function BlogPage({
return (
<BlurFade delay={BLUR_FADE_DELAY * 3 + id * 0.05} key={slug}>
<Link
className="flex items-start gap-x-2 group cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="flex items-start gap-x-3 group cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
href={`/blog/${slug}`}
>
<span className="text-xs font-mono tabular-nums font-medium mt-[5px]">
{String(indexNumber).padStart(2, "0")}.
</span>
<div className="flex flex-col gap-y-2 flex-1">
<p className="tracking-tight text-lg font-medium">
<p className="tracking-tight text-base font-medium">
<span className="group-hover:text-foreground transition-colors">
{post.title}
<ChevronRight
Expand Down
72 changes: 41 additions & 31 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,25 @@
}

:root {
--radius: 0.625rem;
--radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--foreground: oklch(0.09 0 0);
--card: oklch(0.99 0 0);
--card-foreground: oklch(0.09 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0.09 0 0);
--primary: oklch(0.145 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.145 0 0);
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.96 0 0);
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: oklch(0.88 0 0);
--input: oklch(0.88 0 0);
--ring: oklch(0.5 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
Expand All @@ -78,37 +78,47 @@
}

.dark {
--background: oklch(0.18 0 0);
--background: oklch(0.08 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.12 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.12 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--primary-foreground: oklch(0.12 0 0);
--secondary: oklch(0.17 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--muted: oklch(0.17 0 0);
--muted-foreground: oklch(0.58 0 0);
--accent: oklch(0.17 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--border: oklch(1 0 0 / 11%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.4 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.12 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.17 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.4 0 0);
}

@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}

@keyframes marquee-reverse {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}

@layer base {
Expand Down
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export default function RootLayout({
>
<ThemeProvider attribute="class" defaultTheme="dark">
<TooltipProvider delayDuration={0}>
<div className="absolute inset-0 top-0 left-0 right-0 h-[100px] overflow-hidden z-0">
<div className="absolute inset-0 top-0 left-0 right-0 h-[80px] overflow-hidden z-0">
<FlickeringGrid
className="h-full w-full"
squareSize={2}
Expand All @@ -83,7 +83,7 @@ export default function RootLayout({
}}
/>
</div>
<div className="relative z-10 max-w-2xl mx-auto py-12 pb-24 sm:py-24 px-6">
<div className="relative z-10 max-w-2xl mx-auto py-12 pb-24 sm:py-20 px-6">
{children}
</div>
<Navbar />
Expand Down
24 changes: 9 additions & 15 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ContactSection from "@/components/section/contact-section";
import HackathonsSection from "@/components/section/hackathons-section";
import ProjectsSection from "@/components/section/projects-section";
import WorkSection from "@/components/section/work-section";
import SkillsSection from "@/components/section/skills-section";
import { ArrowUpRight } from "lucide-react";

const BLUR_FADE_DELAY = 0.04;
Expand All @@ -22,7 +23,7 @@ export default function Page() {
<div className="gap-2 flex flex-col order-2 md:order-1">
<BlurFadeText
delay={BLUR_FADE_DELAY}
className="text-3xl font-semibold tracking-tighter sm:text-4xl lg:text-5xl"
className="text-3xl font-semibold tracking-tight sm:text-4xl"
yOffset={8}
text={`Hi, I'm ${DATA.name.split(" ")[0]}`}
/>
Expand All @@ -33,7 +34,7 @@ export default function Page() {
/>
</div>
<BlurFade delay={BLUR_FADE_DELAY} className="order-1 md:order-2">
<Avatar className="size-24 md:size-32 border rounded-full shadow-lg ring-4 ring-muted">
<Avatar className="size-20 md:size-28 border rounded-full shadow-sm ring-1 ring-border">
<AvatarImage alt={DATA.name} src={DATA.avatarUrl} />
<AvatarFallback>{DATA.initials}</AvatarFallback>
</Avatar>
Expand All @@ -44,7 +45,7 @@ export default function Page() {
<section id="about">
<div className="flex min-h-0 flex-col gap-y-4">
<BlurFade delay={BLUR_FADE_DELAY * 3}>
<h2 className="text-xl font-bold">About</h2>
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">About</h2>
</BlurFade>
<BlurFade delay={BLUR_FADE_DELAY * 4}>
<div className="prose max-w-full text-pretty font-sans leading-relaxed text-muted-foreground dark:prose-invert">
Expand All @@ -58,7 +59,7 @@ export default function Page() {
<section id="work">
<div className="flex min-h-0 flex-col gap-y-6">
<BlurFade delay={BLUR_FADE_DELAY * 5}>
<h2 className="text-xl font-bold">Work Experience</h2>
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">Work Experience</h2>
</BlurFade>
<BlurFade delay={BLUR_FADE_DELAY * 6}>
<WorkSection />
Expand All @@ -68,18 +69,11 @@ export default function Page() {
<section id="skills">
<div className="flex min-h-0 flex-col gap-y-4">
<BlurFade delay={BLUR_FADE_DELAY * 9}>
<h2 className="text-xl font-bold">Skills</h2>
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">Skills</h2>
</BlurFade>
<BlurFade delay={BLUR_FADE_DELAY * 10}>
<SkillsSection />
</BlurFade>
<div className="flex flex-wrap gap-2">
{DATA.skills.map((skill, id) => (
<BlurFade key={skill.name} delay={BLUR_FADE_DELAY * 10 + id * 0.05}>
<div className="border bg-card/95 border-border ring-2 ring-border/20 rounded-xl h-8 w-fit px-4 flex items-center gap-2">
{skill.icon && <skill.icon className="size-4 rounded overflow-hidden object-contain" />}
<span className="text-foreground text-sm font-medium">{skill.name}</span>
</div>
</BlurFade>
))}
</div>
</div>
</section>
<section id="projects">
Expand Down
52 changes: 52 additions & 0 deletions src/components/magicui/marquee.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { cn } from "@/lib/utils";

interface MarqueeProps {
className?: string;
reverse?: boolean;
pauseOnHover?: boolean;
duration?: number;
children: React.ReactNode;
}

export function Marquee({
className,
reverse = false,
pauseOnHover = false,
duration = 30,
children,
}: MarqueeProps) {
const animationStyle = {
animation: `${reverse ? "marquee-reverse" : "marquee"} ${duration}s linear infinite`,
} as React.CSSProperties;

return (
<div
className={cn(
"group flex overflow-hidden mask-[linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]",
className
)}
>
<div
className={cn(
"flex min-w-full shrink-0 gap-2",
pauseOnHover && "group-hover:[animation-play-state:paused]"
)}
style={animationStyle}
>
{children}
</div>
<div
aria-hidden
className={cn(
"flex min-w-full shrink-0 gap-2",
pauseOnHover && "group-hover:[animation-play-state:paused]"
)}
style={animationStyle}
>
{children}
</div>
</div>
);
}
Loading