I Rewrote My Portfolio From Scratch — Here's What Actually Changed (And Why)
My old portfolio wasn't broken. It just felt wrong. Here's the full story of how I stripped it back, rebuilt the design language, switched toolchains, and added features I'd been putting off for months.

Apr 11, 2026
My old portfolio wasn't bad. It had all the things that feel polished when you first build them: card grids, gradient overlays, staggered animations, rounded-3xl corners everywhere. You look at it and think: yeah, that looks like a modern site.
Then six months pass and you keep opening it and something feels off. Nothing is obviously wrong. But every section is quietly competing for attention. There's no hierarchy. It's just noise.
So I started over on the design language, rewrote most of the components, added a couple of features I'd been putting off for too long, and somewhere in the middle of all this, switched my entire dev setup from Windows to macOS. This post is the full breakdown.
The Design Shift: From "Modern SaaS" to Editorial
The old design lived in a very specific aesthetic bucket I'd call modern SaaS: shadows on cards, borders everywhere, lots of colour, hover-lift animations. It's a style that works great for product landing pages. For a personal portfolio it ends up feeling generic.
The new direction is closer to editorial design. Think tech publication meets printed magazine. The changes sound small individually but add up fast.
No more rounded corners. I removed rounded-3xl from basically everything. Flat, sharp edges give the layout a much more intentional, structured feel.
Left accent bars instead of cards. Instead of wrapping content in bordered card boxes, list rows now have a thin vertical bar on the left that scales in from the center on hover:
<div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200 rounded-sm" /><div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200 rounded-sm" />Mono eyebrow labels. Every section heading now has a small uppercase label above it. The kind of detail you don't consciously notice, but that makes everything feel considered:
<span className="font-mono text-[9px] tracking-[0.45em] uppercase text-gray-500">
{eyebrow}
</span><span className="font-mono text-[9px] tracking-[0.45em] uppercase text-gray-500">
{eyebrow}
</span>Section number watermarks. Big faded numbers (01, 02, 03) sit behind each section. More editorial energy, less web-app energy.
Invert-fill buttons. The hover state on CTAs now runs a fill layer up from the bottom of the button rather than just swapping the background colour:
<button className="group relative inline-flex items-center gap-2 px-7 py-3.5 border border-gray-400 overflow-hidden hover:text-white transition-colors duration-300">
<span className="absolute inset-0 bg-gray-900 translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" />
<span className="relative z-10">View Work</span>
</button><button className="group relative inline-flex items-center gap-2 px-7 py-3.5 border border-gray-400 overflow-hidden hover:text-white transition-colors duration-300">
<span className="absolute inset-0 bg-gray-900 translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" />
<span className="relative z-10">View Work</span>
</button>Border dividers instead of card wrappers. Lists are flat rows with border-b separators. The content breathes. You can actually read it.
The whole palette simplified down to gray-900 and white in dark mode, with emerald only for "currently available" indicators.
The Navbar
Two things had been bugging me about the old navbar: it added a drop shadow on scroll, and the hamburger icon was just two static SVGs swapping in and out.
The scroll behaviour now switches to a border-b instead:
// Before
navRef.current!.classList.add("shadow", "backdrop-blur-xl", "bg-white/70");
// After
navRef.current!.classList.add(
"border-b",
"border-gray-200",
"dark:border-neutral-700",
"backdrop-blur-xl",
"bg-white/80",
"dark:bg-darkPrimary/90"
);// Before
navRef.current!.classList.add("shadow", "backdrop-blur-xl", "bg-white/70");
// After
navRef.current!.classList.add(
"border-b",
"border-gray-200",
"dark:border-neutral-700",
"backdrop-blur-xl",
"bg-white/80",
"dark:bg-darkPrimary/90"
);The hamburger became three motion.span lines that animate into an X:
<motion.span
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
transition={{ duration: 0.2 }}
className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>
<motion.span
animate={open ? { opacity: 0, scaleX: 0 } : { opacity: 1, scaleX: 1 }}
className="block w-5 h-px bg-gray-900 dark:bg-white"
/>
<motion.span
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/><motion.span
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
transition={{ duration: 0.2 }}
className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>
<motion.span
animate={open ? { opacity: 0, scaleX: 0 } : { opacity: 1, scaleX: 1 }}
className="block w-5 h-px bg-gray-900 dark:bg-white"
/>
<motion.span
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>It's a detail that takes twenty minutes to implement and immediately makes the site feel more alive.
On desktop, nav items use layoutId="nav-underline" for a shared-element animated underline that slides between links. On mobile, the menu now has large numbered links and a "MENU" watermark sitting in the background.
The Hero Section (Built From Zero)
There wasn't really a proper hero component before, just the first section with some text in it. I built HeroSection.tsx from scratch.
The dot grid background is a single CSS radial-gradient at 12% opacity:
<div
style={{
backgroundImage: "radial-gradient(circle, #6b7280 1px, transparent 1px)",
backgroundSize: "28px 28px",
opacity: 0.12,
}}
/><div
style={{
backgroundImage: "radial-gradient(circle, #6b7280 1px, transparent 1px)",
backgroundSize: "28px 28px",
opacity: 0.12,
}}
/>The "JS" watermark is my initials in massive gradient-clipped text, sitting to the right of the content area. It's not readable, it's just a shape:
<div
className="absolute -right-4 top-1/2 -translate-y-1/2 font-black select-none pointer-events-none bg-gradient-to-b from-gray-200 to-gray-50 dark:from-[#232628] dark:to-darkPrimary bg-clip-text text-transparent"
style={{ fontSize: "clamp(8rem, 24vw, 22rem)" }}
>
JS
</div><div
className="absolute -right-4 top-1/2 -translate-y-1/2 font-black select-none pointer-events-none bg-gradient-to-b from-gray-200 to-gray-50 dark:from-[#232628] dark:to-darkPrimary bg-clip-text text-transparent"
style={{ fontSize: "clamp(8rem, 24vw, 22rem)" }}
>
JS
</div>The profile image is grayscale at rest and transitions to full colour on hover. I genuinely love this one. It's the kind of detail that makes a visitor do a double-take the first time they see it:
<Image
className="grayscale hover:grayscale-0 transition-all duration-500"
src="/profile.jpg"
alt="Jatin Sharma"
width={300}
height={300}
/><Image
className="grayscale hover:grayscale-0 transition-all duration-500"
src="/profile.jpg"
alt="Jatin Sharma"
width={300}
height={300}
/>Corner cross-tick marks at the four corners of the content area give that blueprint feel without being heavy-handed about it.
Blog Cards: Stripping It All Back
This was probably the most dramatic visual change on the site.
The old blog card was tall, image-dominant, and wrapped in a rounded bordered box. It looked fine. But it was heavy. Reading the blog index felt like scrolling through a gallery.
// Before: big card with image and rounded corners
<motion.article className="group bg-white dark:bg-darkSecondary rounded-3xl overflow-hidden border-2 border-gray-100">
<div className="grid md:grid-cols-2 gap-6 p-6">
{/* image + content */}
</div>
</motion.article>
// After: flat row with accent bar
<motion.article className="group relative border-b border-gray-300 dark:border-neutral-700 last:border-0">
<div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200" />
<Link className="flex items-center gap-4 py-6 pl-4 pr-2">
{/* index + title + arrow */}
</Link>
</motion.article>// Before: big card with image and rounded corners
<motion.article className="group bg-white dark:bg-darkSecondary rounded-3xl overflow-hidden border-2 border-gray-100">
<div className="grid md:grid-cols-2 gap-6 p-6">
{/* image + content */}
</div>
</motion.article>
// After: flat row with accent bar
<motion.article className="group relative border-b border-gray-300 dark:border-neutral-700 last:border-0">
<div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200" />
<Link className="flex items-center gap-4 py-6 pl-4 pr-2">
{/* index + title + arrow */}
</Link>
</motion.article>No images. No author avatar. No "Read more" button. Just the content. The result is a blog index you can actually scan. You can read ten titles in the time it used to take to read three.
Skills Section: Marquee and Denser Grid
The skill cards went from tall centered icon boxes to compact horizontal rows in a grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 grid.
The new addition I'm happiest about is the marquee ticker, a continuous horizontal scroll of all skills sitting between the section header and filter buttons:
<div className="flex animate-marquee gap-10 w-max">
{[...skills, ...skills].map((skill, i) => {
const Icon = skill.Icon;
return (
<div key={i} className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 select-none">
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
<span className="text-[10px] font-mono uppercase tracking-[0.25em] whitespace-nowrap">
{skill.name}
</span>
</div>
);
})}
</div><div className="flex animate-marquee gap-10 w-max">
{[...skills, ...skills].map((skill, i) => {
const Icon = skill.Icon;
return (
<div key={i} className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 select-none">
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
<span className="text-[10px] font-mono uppercase tracking-[0.25em] whitespace-nowrap">
{skill.name}
</span>
</div>
);
})}
</div>The trick is duplicating the array so the scroll loops seamlessly. No library needed, just a CSS @keyframes translate.
The filter also now shows a count next to each category name, and switching categories triggers a full stagger re-entry on the grid rather than trying to animate individual items in and out.
Table of Contents: Panel to Drawer
The old TOC was a fixed left panel on desktop. It worked, but it was always there, always occupying space, always creating layout tension with the article content.
The new version is a FAB button that opens a left-side drawer:
{/* FAB */}
<motion.button
onClick={() => setOpen((o) => !o)}
className="fixed bottom-6 left-6 z-40 flex items-center gap-2 px-3 h-9 bg-gray-900 dark:bg-white text-white dark:text-gray-900 font-mono text-[10px] tracking-[0.35em] uppercase"
>
<BsListUl className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Contents</span>
</motion.button>
{/* Drawer */}
<motion.aside
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
className="fixed top-0 left-0 bottom-0 w-full sm:w-80 bg-white dark:bg-darkPrimary border-r border-gray-200 dark:border-neutral-700 flex flex-col"
>{/* FAB */}
<motion.button
onClick={() => setOpen((o) => !o)}
className="fixed bottom-6 left-6 z-40 flex items-center gap-2 px-3 h-9 bg-gray-900 dark:bg-white text-white dark:text-gray-900 font-mono text-[10px] tracking-[0.35em] uppercase"
>
<BsListUl className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Contents</span>
</motion.button>
{/* Drawer */}
<motion.aside
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
className="fixed top-0 left-0 bottom-0 w-full sm:w-80 bg-white dark:bg-darkPrimary border-r border-gray-200 dark:border-neutral-700 flex flex-col"
>Removing the fixed panel also let me drop four dependencies I didn't need anymore: useScrollPercentage, useWindowSize, lockScroll, and removeScrollLock. The drawer just uses a backdrop click to close.
The Books Page
This is the biggest entirely new feature.
I've been tracking my reading on Hardcover and wanted to surface that data on the portfolio. Hardcover has a GraphQL API so I built a small client:
async function hardcoverQuery<T>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("https://api.hardcover.app/v1/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.HARDCOVER_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
return json.data as T;
}async function hardcoverQuery<T>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch("https://api.hardcover.app/v1/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.HARDCOVER_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
return json.data as T;
}The API route caches for 24 hours with stale-while-revalidate so it doesn't hammer the endpoint:
res.setHeader(
"Cache-Control",
"public, s-maxage=86400, stale-while-revalidate=43200"
);res.setHeader(
"Cache-Control",
"public, s-maxage=86400, stale-while-revalidate=43200"
);The page has reading stats, a year-goal progress bar that animates in on scroll, a tabbed shelf for the three reading statuses, and a debounced search across title and author. It's the kind of page that only makes sense on a personal portfolio.
siteConfig.ts: One Source of Truth
My name, email, job title, section copy, and social links were scattered across maybe a dozen different files. When I needed to update something, even just my job title, I had to grep for it and hope I caught every instance.
Now there's a single content/siteConfig.ts:
const siteConfig = {
person: {
name: "Jatin Sharma",
email: "work.j471n@gmail.com",
location: "Based in India",
},
home: {
hero: {
rolePrefix: "Tech Lead at",
companyName: "KonnectNXT",
primaryCta: { label: "Download Resume", url: "https://bit.ly/j471nCV" },
},
},
} as const;const siteConfig = {
person: {
name: "Jatin Sharma",
email: "work.j471n@gmail.com",
location: "Based in India",
},
home: {
hero: {
rolePrefix: "Tech Lead at",
companyName: "KonnectNXT",
primaryCta: { label: "Download Resume", url: "https://bit.ly/j471nCV" },
},
},
} as const;socialMedia.ts and user.ts both derive from siteConfig now. One change propagates everywhere. It's the kind of refactor you keep putting off until you finally do it and immediately wonder why you waited.
Syntax Highlighting: Light and Dark
Previously everything used one-dark-pro regardless of colour scheme. Now code blocks adapt properly:
// Before
[rehypePrettyCode, { theme: "one-dark-pro" }]
// After
[rehypePrettyCode, {
theme: {
dark: "one-dark-pro",
light: "github-light",
},
}]// Before
[rehypePrettyCode, { theme: "one-dark-pro" }]
// After
[rehypePrettyCode, {
theme: {
dark: "one-dark-pro",
light: "github-light",
},
}]For MDX local content I went with andromeeda (dark) and catppuccin-latte (light), a slightly warmer combination.
The CodeTitle component also got redesigned. The old bordered box is now a top accent line and a clean mono label:
// Before
<div className="bg-white rounded-tl-md rounded-tr-md p-3 border border-black">
// After
<div className="!mt-4 mb-[14px]">
<div className="h-0.5 w-full bg-gray-900 dark:bg-white" />
<div className="bg-white dark:bg-darkSecondary border border-b-0 border-gray-200 dark:border-neutral-700 px-4 py-2 flex items-center gap-2 font-mono overflow-x-auto">
<Icon className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[10px] tracking-[0.35em] uppercase text-gray-600 dark:text-gray-400">
{title || lang}
</span>
</div>
</div>// Before
<div className="bg-white rounded-tl-md rounded-tr-md p-3 border border-black">
// After
<div className="!mt-4 mb-[14px]">
<div className="h-0.5 w-full bg-gray-900 dark:bg-white" />
<div className="bg-white dark:bg-darkSecondary border border-b-0 border-gray-200 dark:border-neutral-700 px-4 py-2 flex items-center gap-2 font-mono overflow-x-auto">
<Icon className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[10px] tracking-[0.35em] uppercase text-gray-600 dark:text-gray-400">
{title || lang}
</span>
</div>
</div>The Uses Page: Full Windows to macOS Migration
I switched from Windows to macOS this year and my /uses page was so out of date it was almost embarrassing.
Gone: Windows 11, Edge, Sublime Text, ShareX, Ditto, 7-Zip, Flameshot, Notepad++, Google Keep, Microsoft Todo.
In: macOS, Homebrew, Raycast (the thing I miss most when I'm on any other machine), Rectangle, iTerm2, Warp, Oh My Zsh, Arc as my primary browser, CleanShot X, Obsidian.
The whole category structure got reworked too: System and OS, Terminal and CLI, Development, Design and Creativity, Productivity, Browsers, Communication.
The Smaller Stuff
A reusable PageHeader component: the watermark + eyebrow + title + description pattern was copy-pasted into every page. Extracted it once, imported it everywhere.
fallback: "blocking" on the blog: changed from fallback: false so newly published posts go live immediately without requiring a full rebuild.
BlogLayout cleanup: removed the Newsletter section, share buttons, and bookmark feature from individual post pages. Just the article now.
Animation cleanup: replaced dozens of imported FramerMotionVariants with simple inline transitions. Less code, same feel, easier to read:
// Before: importing complex variants from a separate file
import { FadeContainer, popUp } from "@content/FramerMotionVariants";
// After: simple inline
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
>// Before: importing complex variants from a separate file
import { FadeContainer, popUp } from "@content/FramerMotionVariants";
// After: simple inline
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
>What's Next
The Projects page still needs the same treatment the blog got. I've already plumbed in the featured prop on Project.tsx, just haven't gotten there yet. There's also a /stats page refresh on the list.
But the core is done and it finally feels like mine.
All the code is open source at github.com/j471n/j471n.in. If you want to lift any of the patterns, the watermark technique, the invert-fill buttons, the marquee, the accent bars, go for it.