Building adityachauhan.dev

Okay so this is a blog post about this website. Very meta, I know. But a few people have asked how this site was built and honestly writing it down felt like a good excuse to actually go through my own code, which, spoiler, I had not done in a while.

Why this exists at all

There was no portfolio before this. Like, nothing. I just wanted a personal site and started from a completely blank slate.

I’m pretty into dark, minimal, “engineered looking” UI. The kind of stuff you see on n1.xyz, 1x.tech, linear.app. Clean, a little cold, very intentional. So while I was poking around for portfolio inspiration, I came across Anthony Fu’s site, antfu.me, and just kind of fell in love with how it looks and feels.

Here’s the slightly embarrassing part. Anthony’s site is fully open source (it’s Vue + Vite), and I still haven’t actually opened the source. I swear I’m not lying about this. I just looked at the live site, liked the vibe, and decided to build my own version of that vibe from scratch.

So to be very upfront about it: this site is heavily inspired by antfu.me. I’m not claiming the ideas here are mine, just that I built my own implementation of them, in a completely different stack.

Which brings me to the stack question. Anthony uses Vue and Vite because, well, he’s a core contributor to both of those projects, so of course he’d build his site with them. Makes total sense for him. For me, doing the same thing would be like using an AK-47 to kill a chicken. I know Astro pretty well, it’s a lean content-first framework that’s basically perfect for a static portfolio + blog, so that’s what I went with.

The design language

Let’s talk about how the thing actually looks.

The dotted background

If you’ve poked around the site you’ve probably noticed the background isn’t quite static. There’s a grid of dots covering the whole page, and they shimmer a little, and they react to your cursor.

This is not CSS, not SVG, none of that. It’s a full screen <canvas> running hand rolled 2D Perlin noise. The whole thing lives in src/scripts/dotgrid.js and gets kicked off once from the root layout:

<script>
  import { initDotGrid } from '../scripts/dotgrid.js';
  initDotGrid();
</script>

The canvas sits fixed behind everything (z-index: 0, pointer-events: none), and all the actual page content sits in a wrapper above it at z-index: 1. The near black background color (#080808) shows through the gaps between dots.

The core loop looks roughly like this (trimmed down):

const SPACING = 28;        // px between dots
const NOISE_SCALE = 0.006; // spatial frequency
const NOISE_SPEED = 0.0008; // how fast the field evolves
const BASE_MIN = 0.05, BASE_MAX = 0.20; // dot opacity range
const SPOT_RADIUS = 120, SPOT_BOOST = 0.7; // cursor spotlight

function draw() {
  ctx.clearRect(0, 0, W, H);
  for (const d of dots) {
    const n = noise2d(d.x * NOISE_SCALE, d.y * NOISE_SCALE + t);
    let opacity = BASE_MIN + ((n + 1) * 0.5) * (BASE_MAX - BASE_MIN);

    const dist = Math.hypot(d.x - mouseX, d.y - mouseY);
    if (dist < SPOT_RADIUS) {
      const factor = 1 - dist / SPOT_RADIUS;
      opacity = Math.min(opacity + smoothstep(factor) * SPOT_BOOST, 1);
    }

    ctx.beginPath();
    ctx.arc(d.x, d.y, DOT_RADIUS, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(255,255,255,${opacity.toFixed(3)})`;
    ctx.fill();
  }
  t += NOISE_SPEED;
  rafId = requestAnimationFrame(draw);
}

There’s no simplex-noise or any noise library in here. The permutation table, the fade curve, the gradient hashing, all of it is written from scratch. And one fun side effect of that, the permutation table gets shuffled with Math.random() on every page load, so the exact pattern of the dots is a little different every time you visit.

There are two effects layered on top of each other. One is a slow ambient shimmer, just sampling the noise field at a slowly increasing t so it feels alive. The other is the cursor spotlight, dots within 120px of your mouse brighten with a smoothstep curve.

Honest confession time though. This loop runs uncapped requestAnimationFrame, redrawing every single dot every frame. At 28px spacing that’s a few hundred to a couple thousand ctx.arc() calls per frame on a big screen. It also doesn’t check prefers-reduced-motion (unlike basically everything else on the site, more on that later), and it doesn’t pause when the tab is hidden, so it just keeps chugging away in a background tab. There’s even a cleanup function written that cancels the RAF loop and removes the canvas, and it’s just never called. Stuff to tighten up eventually.

Fonts, the logo, and the terminal breadcrumb

Three fonts, all from Google Fonts:

  • DM Sans for body text
  • DM Mono for anything “UI-ish”, dates, code blocks, the breadcrumb thing I’ll get to in a second
  • Caveat which is used in exactly one place

That one place is the logo. The “ac” in the top left isn’t an image or an SVG, it’s literally the text <em>ac</em> set in Caveat with some italic styling to get that handwritten look:

<a href="/" class="nav-logo" aria-label="Home">
  <em>ac</em>
</a>
.nav-logo {
  font-family: 'Caveat', cursive;
  font-size: 1.6rem;
  font-weight: 600;
  color: var(--text);
  letter-spacing: -0.02em;
  opacity: 0.85;
}
.nav-logo:hover { opacity: 1; }

Being plain text means it’s crisp at any zoom level and needs zero assets. Hover just nudges the opacity up a bit. That’s it.

Then there’s the > cd .. thing you’ll see at the bottom of the Projects, Demos, and Blog pages. This is just a normal link styled to look like a shell prompt, in DM Mono, dim grey by default (--text-3) and brightening on hover (--text-2). On a blog post it points back to /blog, everywhere else it points to /, sort of mimicking how cd .. would behave if these were actual directories.

I’ll admit it’s copy pasted across like four different files instead of being its own component. One of those “I’ll refactor this eventually” things.

The color system

The whole palette is greyscale. No accent color, nothing. There used to be a green in an earlier version (more on that in a bit) but it got removed entirely during the redesign.

:root {
  --bg:       #080808;
  --text:     #e5e5e5;
  --text-2:   #a1a1a1;
  --text-3:   #666;
  --border:   rgba(255, 255, 255, 0.08);
  --border-2: rgba(255, 255, 255, 0.12);
  --max-w:    60ch;
  --nav-h:    56px;
}

Three steps of grey on a near black background, that’s the entire hierarchy. Borders are white at low opacity rather than solid greys, which keeps them feeling consistent no matter what’s behind them.

The width and nav height are tokenized too, --max-w is the 60ch reading column, --nav-h is reused both as the nav’s height and as the top padding on the content wrapper (so content doesn’t slide under the fixed nav).

Radii and spacing, on the other hand, are not tokenized at all. They’re just hardcoded per component, 4px here, 8px there, pill buttons at 9999px. So the color system is rigorous and the spacing system is, let’s say, vibes based.

The sliding enter animation

Okay this one deserves its own section because I think it’s one of my favorite details on the site, and also because the credit chain behind it is fun.

If you scroll through any page here, you’ll notice things don’t just appear, they fade in and slide up slightly, one after another, like they’re entering the page in sequence. This effect actually comes from Paco Coursey’s site, paco.me. I found out about it through a blog post by, you guessed it, Anthony Fu, where he reverse engineers exactly how Paco implemented it. So this is a chain of “stealing like an artist”: Paco built it, Anthony broke it down and adapted it, and I adapted Anthony’s version into Astro.

The actual animation is just one keyframe:

@keyframes slide-enter {
  0%  { opacity: 0; transform: translateY(10px); }
  to  { opacity: 1; transform: none; }
}

Fade in, rise 10px. That’s the whole visual effect. Everything else is just about staggering when each element plays it.

There are two ways this gets applied on the site. The first is manual, for individual elements:

[data-animate] {
  --stagger: 0;
  --delay: 120ms;
  --start: 0ms;
}

@media (prefers-reduced-motion: no-preference) {
  [data-animate] {
    animation: slide-enter 0.6s both;
    animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
  }
}

@media (prefers-reduced-motion: reduce) {
  [data-animate] { animation: none; }
}

And then in the markup you just give each element its position in the sequence:

<h1 data-animate style="--stagger: 1">...</h1>
<p  data-animate style="--stagger: 2">...</p>
<p  data-animate style="--stagger: 3">...</p>

Element 2 waits 240ms, element 3 waits 360ms, and so on, and you get that cascading effect. The cd .. breadcrumb on every page gets a deliberately high stagger value so it’s always the last thing to slide in.

The second mechanism is for blog posts, where the content is rendered from Markdown and you obviously can’t go hand annotating every single paragraph. For that there’s .slide-enter-content, which uses :nth-child to assign the stagger automatically:

@media (prefers-reduced-motion: no-preference) {
  .slide-enter-content > * {
    --stagger: 0; --delay: 150ms; --start: 0ms;
    animation: slide-enter 1s both 1;
    animation-delay: calc(var(--start) + var(--stagger) * var(--delay));
  }
  .slide-enter-content > *:nth-child(1)  { --stagger: 1; }
  .slide-enter-content > *:nth-child(2)  { --stagger: 2; }
  /* ... goes up to nth-child(20) */
  .slide-enter-content > *:nth-child(20) { --stagger: 20; }
}

Every direct child of the wrapper gets staggered automatically based on its position. The whole thing is pure CSS, no JavaScript, no IntersectionObserver, it just fires once on page load. And both mechanisms are wrapped in prefers-reduced-motion, so if you’ve got that turned on, none of this plays.

One small limitation, the :nth-child list is hardcoded up to 20. If a blog post somehow has more than 20 top level elements, the 21st one just plays instantly with no stagger. Fine for now, but a little landmine for a very long post someday.

How the site is actually put together

Quick tour of the file structure, because I think the way this is organized is more “every page does its own thing” than “one big shared template”, which surprised me a bit when I went back through it.

src/
├─ pages/
│  ├─ index.astro          → /            home / hero
│  ├─ projects.astro       → /projects    project grid + category nav
│  ├─ demos.astro          → /demos       video masonry
│  └─ blog/
│     ├─ index.astro       → /blog        post list, Blog/Notes tabs
│     └─ [slug].astro      → /blog/<slug> individual post
├─ layouts/
│  └─ PostLayout.astro
├─ components/
│  ├─ Layout.astro
│  ├─ TopNav.astro
│  └─ Sidebar.astro        ← more on this in a second
├─ content.config.ts
├─ scripts/dotgrid.js
└─ styles/global.css

Every page imports Layout and TopNav itself and assembles them directly:

---
import Layout from '../components/Layout.astro';
import TopNav from '../components/TopNav.astro';
---
<Layout title="Projects — Aditya Chauhan" description="Things I've built.">
  <TopNav activePage="projects" />
  <main class="container wide page-main"> ... </main>
  <footer class="site-footer"></footer>
</Layout>

Layout.astro is the actual document root, it’s the only file with <!DOCTYPE>, <html>, <head>, <body>. It handles the meta tags, loads the fonts, imports the global CSS, kicks off the dot grid, and wraps everything in the .page-content div that sits above the canvas. It does NOT handle nav or footer though, those are opt-in per page. There’s a small nice touch in here too, page titles get passed in like "Projects — Aditya Chauhan" and the layout strips the — Aditya Chauhan part so your browser tab just shows Projects.

TopNav.astro is fully self contained, markup, styles, and a tiny script for the mobile drawer all in one file. The active link highlighting is entirely build time, each page just passes activePage="projects" or whatever, and Astro’s class:list helper adds the active class. No JS, no URL sniffing. The home page deliberately has no nav link at all, the “ac” logo serves as the home link instead. On mobile (below 620px) the link row hides and a hamburger reveals a full screen drawer, which does include a Home link.

And then there’s Sidebar.astro, which is, and I only realized this while going through everything for this post, completely dead code. Nothing imports it. It’s a leftover from a much earlier version of the site, before the antfu inspired redesign. It references CSS variables like --green, --sidebar-w, --mid, none of which exist anymore. It even has its own IntersectionObserver based scrollspy and links to anchors like #about, #work, #research, #contact that don’t exist as a single page layout anymore.

So, fun fact, this site used to have green in its color palette at some point, and a completely different single page layout, and the only evidence left is this one orphaned file sitting in src/components doing absolutely nothing. I should probably just delete it. I probably will, right after I finish writing this sentence. Maybe.

The shared layout primitives are pretty simple: .container is the 60ch reading column, .container.wide bumps that to 860px for the grid heavy pages, and .page-content has padding-top: var(--nav-h) so nothing slides under the fixed nav. Small inconsistency I noticed, the nav itself hardcodes height: 56px instead of using the --nav-h token, even though they need to stay in sync. Same value, just not DRY.

Also worth saying, this is a plain multi page app. Every navigation is a full page reload. No View Transitions, no client side router, nothing like that. The dot grid canvas, including its Perlin permutation table, literally gets recreated from scratch on every page load.

The blog system, and some honest confessions

The blog runs on Astro’s content collections, with two separate collections defined via Zod schemas:

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    date:  z.coerce.date(),
    tags:  z.array(z.string()).optional(),
  }),
});

const notes = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/notes' }),
  schema: z.object({
    title: z.string(),
    tag:   z.string(),
    date:  z.coerce.date(),
  }),
});

Small thing I only noticed while writing this, the blog schema has plural, optional tags, and the notes schema has a singular, required tag. Not on purpose, just an inconsistency that happened along the way.

The /blog page fetches both collections at build time, sorts everything newest first, and groups blog posts into a Map<year, posts[]>:

const posts = await getCollection('blog');
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());

const byYear = new Map();
for (const post of posts) {
  const y = post.data.date.getFullYear();
  if (!byYear.has(y)) byYear.set(y, []);
  byYear.get(y).push(post);
}
const years = [...byYear.keys()].sort((a, b) => b - a);

Both the Blog tab and the Notes tab are rendered into the DOM at build time, the tab switching is just a tiny script toggling hidden and an active class, with proper ARIA roles (tablist, tab, tabpanel, aria-selected).

Now for the confessions. Writing this post, I went and actually read through this code properly for the first time in a while, and found a few things:

First, there’s a description field that gets passed all the way through to PostLayout, but it doesn’t exist in either schema. So it’s always undefined, and the {description && <p class="post-desc">{description}</p>} guard just silently never renders anything. It’s a whole prop that does nothing.

Second, the Notes tab has no detail pages. Blog posts link to /blog/{slug}, but note rows are just plain <div>s with no link at all. So the Notes feature is maybe 80% built, the collection exists, the schema exists, the tab exists, there’s even one real note in there, but there’s no way to actually click into it and read it.

Third, because there are currently only 5 blog posts and they’re all from 2026, the year grouping logic has literally never had to handle more than one year. It’s built for a scale it doesn’t have yet, which I guess is either forward thinking or just me overengineering a list of 5 things, you decide.

The projects page

The /projects page groups everything into categories, Agentic AI, Web3 SDK, Simulation & Modeling, AI Research, each rendered as its own section with a big ghosted watermark of the category name behind it (outlined text, low opacity, purely decorative).

The data is just a hardcoded array:

const categories = [
  {
    id: 'agentic-ai',
    name: 'Agentic AI',
    projects: [
      { icon: '<svg ...>...</svg>', name: 'Sift', desc: '...', github: '...' },
      // ...
    ],
  },
  // web3-sdk, simulation-modeling, ai-research...
];

A project’s category is purely “which array it lives in”, there’s no explicit category field. Every card links straight out to its GitHub repo.

Now, the thing on the left side of this page that fades in when you hover near the edge, the little icon with the category list. I had it in my head that this was a scrollspy, the kind of thing that highlights whichever section you’re currently scrolled past. Turns out, going back through the code, it’s not that at all. It’s pure CSS hover:

.sidebar-toc { position: fixed; left: 0; top: 0; height: 100vh; }

.sidebar-list {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.sidebar-toc:hover .sidebar-list {
  opacity: 1;
  pointer-events: auto;
}

That’s it. No IntersectionObserver, no scroll listener, no active state tracking. It’s just a hover reveal jump menu that happens to look like a scrollspy if you don’t look too closely. I genuinely thought I’d built a scrollspy and it turns out I built a hover menu wearing a trench coat. It also just disappears entirely below 1180px width, with nothing replacing it on smaller screens, so on mobile you just scroll like a normal person.

The icons next to each project are all inline hand drawn SVGs, no icon library, no icon font. Some are pretty generic (a funnel for Sift, a clock for Temporal Blindspot), but a few are actually custom drawn for the project, Cairn’s icon is a little stack of stones, txfence has its own “tx” monogram. They use stroke="currentColor" so they pick up whatever text color is around them, and they fade in a bit more on hover.

Smaller honest notes: the project names and descriptions use a raw #c8c8c8 and var(--text) at half opacity instead of the proper --text-2 / --text-3 tokens, a little drift from the rest of the site. And the subtitle on this page currently says “Projects that I created or maintaining”, which, yeah, grammar. I’ll either fix that or just leave it as a tiny Easter egg, haven’t decided.

The demos page

This is probably the page that gets the best reaction when people see it, and also, I’ll be honest, the one I’d be most nervous about someone actually reading the source of.

Each demo is an entry in a hardcoded array with a name, a date, a link to the live thing, a path to a local mp4, a GitHub link, and a blurb field:

{
  name: 'Temporal Blindspot',
  blurb: '<strong>FROSTBYTE HACKATHON 2026</strong><br>🏆 Winner<br><br>Can you really trust...',
  date: 'Mar 2026',
  link: 'https://temporal-blindspot.vercel.app/',
  video: '/demos/temporal-blindspot.mp4',
  github: 'https://github.com/AdityaChauhanX07/temporal-blindspot',
}

That blurb is a raw HTML string, and it does a LOT. The hackathon name, the little trophy and “Winner” text, the description, sometimes a list of tag pills, sometimes a code snippet, all of it is hand written HTML sitting inside one string, injected with set:html. There’s no won: true flag or badge component anywhere, I just typed 🏆 Winner directly into the string. It works, but it’s wildly inconsistent, some demos have tag pills, some don’t, some have bullet lists, some are just prose. This is, by a good margin, the least structured part of the whole site.

The video cards themselves are real <video> elements:

<video src="/demos/temporal-blindspot.mp4" autoplay muted loop playsinline preload="metadata" />

They’re not cropped, width: 100%; height: auto, so each video keeps its natural aspect ratio. That’s actually what creates the staggered, masonry-ish look, the cards are just different heights because the videos are different shapes.

Speaking of masonry, I also thought this was done with CSS columns, and again, going back through it, nope. It’s a 3 column CSS grid, and the demos get pre sorted into those columns at build time with simple round robin, demos[i % 3] style. Each column is just a flex stack. Because it’s round robin instead of a proper “shortest column first” masonry algorithm, the columns don’t height balance, if one column happens to get a run of tall videos, it just ends up longer than the others. It’s the simpler, less correct, but much easier version, and honestly you probably can’t tell unless you’re looking for it.

And now the part I’m least proud of. There are 10 of these videos, totaling about 42.7MB, and every single one of them autoplays as soon as the page loads. preload="metadata" is set, which is supposed to be the lighter option, but autoplay basically overrides that intention, browsers need to actually buffer a playing video. So visiting this page pulls down a meaningful chunk of video data all at once, including videos that are nowhere near the viewport. The fix here is pretty well known, use an IntersectionObserver to only play videos that are actually visible, and pause the rest, but I never wrote that part. There’s also no poster image and no width/height attributes on the videos, so there’s a bit of layout shift as each one resolves its actual size.

There’s also a :global(code) escape hatch in the CSS specifically because the blurb HTML, being injected via set:html, doesn’t get Astro’s normal scoped styles, so I had to reach outside the scope just to make injected <code> blocks wrap properly. And the page header has a CSS rule that looks like padding-left: calc(2rem + 29%), which is exactly as arbitrary as it sounds, it’s a hand tuned magic number to get the title to visually line up over the second and third columns, with an !important override at tablet widths because of course there is.

So yeah. Visually I think this is the strongest page on the site. Structurally it’s held together with raw HTML strings, a round robin pretending to be masonry, and a magic number in a media query. Software.

What’s next

Honestly, just keep building and keep adding stuff. There’s no grand roadmap, I’ll keep writing posts, keep adding projects and demos as I make them, and probably keep tweaking little details on the site itself whenever something bugs me enough.

Lessons learned

I went into this post expecting to write a nice clean “here’s how I built my site” walkthrough, and instead spent most of it discovering things about my own codebase in real time. So I guess the actual lesson is this:

The real lesson I learned building this site is that I apparently didn’t know how my own site worked. Writing this post taught me more about it than building it did. Turns out I have a dead Sidebar.astro I forgot existed, a description field that’s never once rendered, and a “scrollspy” that’s just a hover menu wearing a trench coat.

Anyway. That’s the site.