How I made my new portfolio

Dec 25, 2023 - 17 minutes

Recently, while working on my coterm application for Stanford CS, I ran into a (optional) question asking if I had a personal website to share. That got me thinking about my previous portfolio1 that I already had some gripes with (even when it launched), and now a very important program is asking to see it? Um, I’m not sure if it’s “production-ready” or “professional enough” for the real world. It was adventurous and not taking itself too seriously (as do I), but it had some fatal flaws that made redesigning it from scratch way easier.

I also fell into the trap that so many other front-end developers find themselves in of trying a new framework for every project. However, I’m currently very happy with the way the new site has turned out, especially the experience in managing content on the blog and the work cards. I wanted to use this space to catalog how I went about creating this and anything I learned that might be useful to someone in the future.

The Issues

The old site was this neo-brutalist “2001: A Space Odyssey”-inspired piece of development hell that ran on 11ty using Nunjucks, Sass, and TypeScript. My original goal was to build a stupidly fast simple portfolio built around color and typography. It relied on a font called Archiv Grotesk2 from Tomas Clarkson with each page using each of the pastel spacesuit colors. In theory, it might have gone well had I been less of an idiot, but my goals weren’t anything unusual or even special:

  1. Build a static website
  2. Load a minimal amount of external content
  3. Inline all above-the-fold CSS
  4. Be accessible and look good 💅

Goals #2 and #4 were pretty easy, but let me tell you - getting a solid build out of this stack was so difficult that I would build a good enough version and then fix it by hand. Again, I probably was being a bit of an idiot but I wasn’t getting the developer experience I wanted.

11ty and me

So, in theory, 11ty is awesome. It supports a decently wide range of templating languages, it’s mostly un-opinionated, and it just builds everything without an underscore in front. Awesome, right? Yes, until you want to import an NPM module, and then everything goes to 💩 real fast. While 11ty is fast, that’s at the expense of build operations like bundling – it didn’t even support ESM until 3.0 (which is in canary). Note that this is a purposeful choice and not necessarily a knock, but I found it difficult especially in importing dependencies.

In addition, 11ty doesn’t support Sass or Typescript out of the box, so my package.json looks insane with a ton of watch:* and build:* commands so npm-run-all can catch them. I tried setting it up with Vite but I couldn’t get a good build, so I instead would do a partial build with 11ty and then manually copy and rewire JS dependencies3 as I found it easier than dealing with file pass-thru. However, I will admit that 11ty offers a microscopic output size and there is absolutely no weight.

Lastly, I found that 11ty’s documentation reiterates its focus on data and content generation, with very little emphasis placed on styling or scripting. While 11ty is catching up with support for partial hydration (via the island architecture) or SSR, and although they have a strong community that has written so many guides, it was clear to me when I approached a redesign that I wanted to try something different.

Smooth Scroll Sadness

The madpeople at Studio Freight have this fantastic smooth scroll library called Lenis that’s fantastic to work with. The downside is that, due to how Safari processes requestAnimationFrame versus Chrome or Firefox (which leads me to assume it’s a JS engine issue), the homepage animation where the menu comes flying in from the right scrolling infinitely and slows to a halt is a jittery mess. Additionally – and this is true of all smooth scroll libraries as far as I know – for a perfect smooth scroll, the end of the container must be identical to the beginning.

My homepage used four circles as a menu that were spread out slightly wider than the screen to indicate the ability to scroll, and to look like there was a direct portal from one side to another. So, to get the copies of four circles to line up the start boundary, with each circle being 36vmin, the container width is defined as calc(max(calc(100vw / 3.4 - $circle-size), 1rem) * 4 + $circle-size * 4 + 100vw) which was one of the stupider lines of CSS I’ve ever written (this is foreshadowing).

At the end of the day, this original portfolio felt more experimental than useful, and I may have been able to get the same experience – even a better one – out of 11ty if I tried harder. But, in my opinion, I would’ve had to try too hard for what it’s worth.

The New Goals

New, and improved, with a major emphasis on developer experience for my sake.

  • Build a static website in one shot
  • Inline all above-the-fold CSS
  • Load a minimum of external content
  • Be accessible and look better
  • Be component-driven and easily editable4


The design was done in Figma by designing the desktop first (see a brief rant). The primary font is Rubik which is a lovely (but sometimes too heavy) sans serif, with a small supporting role from Brygada 1918 which is an elegant serif font (with a really strange bold italic f). These fonts aren’t perfect, and I know that better fonts can be found, but these are free and look good enough. Also, Fira Code is my monospace font of choice and it’s perfect. No notes.


The color scheme is based on Flexoki from Obsidian CEO Steph Ango. I might be one of the biggest fans of Flexoki of all time. It’s the right amount of minimalist and contrasty, offers a good amount of flexibility, and I think it meets the goal of feeling like the ink of a book in candlelight.

I made some changes that are personal preference:

  • The dark yellow feels a little dirty to me and I am aware that it is very difficult to make a good dark yellow. I remapped yellow-400 -> #E0A500 and yellow-600 -> #BF8000. It’s more of an orange-gold now.
  • The paper tone feels a bit too sepia to me, so I swapped paper -> #FAFAF4 which is way less saturated and a bit brighter. I also remapped all of the incremental base tones with the same black (#100F0F) using opacity.

I’m really happy with the color scheme, and I found that it lends itself to utility color classes with Tailwind.


This time, I took a much more conservative approach to design. I still have this weird love for the color-per-page look that I did with my first portfolio, but now I’ve toned it down to just the header. The homepage is super simple, and I wanted that to minimize load time and set up the color theming. I did have bigger plans for the homepage to perhaps show a page preview on hover, but I’m happy with its current minimalism.

Everything else is pretty straightforward. All main pages have a large header with the page name and a “glance” description. The work and ideas pages use a filterable card-based design, and about and contact are two columns. That’s it. My goal was a layout that looked good on all devices and was compact yet still breathable and I think that I’m close. Closer than my first portfolio.

A brief rant

Whenever I think of the best websites, I always see their desktop version. I do most of my web browsing and development on a desktop. Therefore, when I go to design a website, I start with the desktop design. And every time I do, it’s a bad idea. Starting with the desktop design gives you too much freedom and too much space to make smart decisions. I am a (less than) amateur designer so I’m sure experts don’t have this issue, but I struggle with scaling, positioning, spacing, and everything else once it comes time to make a mobile design, or even implement the desktop version. Starting with a mobile design forces you to think about space as a limited resource and use it carefully. It forces you to think about overflow and constraints and touch targets and all these other things we take for granted on the desktop.


My new portfolio is built on Astro 4.0, using Astro’s templating language, TypeScript, Tailwind / Sass, and markdown / MDX for work and blog content. I originally was running on Node.js using pnpm, but I’m now using Bun for fun. Just looking at my build logs, I don’t see any performance improvement but my site is relatively small with ~30 sec compile times so your mileage may vary.

Why Astro?

View transitions. I mean, that was the main reason I decided to look into it, based on this article from Codrops. Getting started was incredibly easy as it’s very much an all-inclusive package, so I stuck with it. Now that I’ve worked very closely with its static site features and have completely ignored its selling point of islands, I have some thoughts.

The Good

Tooling is fantastic. I had very few build issues and most of them were my fault or due to bad integrations (more on that in a second). Scripts, styles, even images, and Fontsource font imports just work. The first-class integrations for UI, SSR, and others (Tailwind, MDX, sitemap, etc.) work great and Astro will even auto-insert them into your config which is a nice touch. It supports content collections, view transitions, prefetch, and markdown out of the box, and I found their templating language generally pretty good with decent editor support.

Templating is pretty simple, with no weird attribute names like React, just normal HTML with some curly braces to do an insert. It’s very useful that the frontmatter in Astro files is run at compile time, allowing me to run costly operations without runtime cost. Also, I freaking love the class:list directive that runs on clsx as it helps with all of the complicated conditional classes that Tailwind sometimes demands.

Project structure is more opinionated: content collections go in /content and file-based routing pages go in /pages, but everything else is a recommendation. Astro Components are easy to use, and the slot works well enough. I do appreciate the compile-time style scoping, and it’s very easy to opt-out. Lastly, I found the documentation pretty good with a fair number of guides, so I think it’s a reasonably easy framework for any front-end-familiar developer to pick up.

I haven’t used the islands (for the reasons below) or SSR, so I don’t have any comments on them.

The Bad

Astro is a very new framework at about ~2 years old. There are some growing pains because of this, and there are a good number of plugins that don’t support the newest version. Working with plugins for Astro is pretty easy, but it’s not obvious in what order they execute, and debugging is difficult. It’s especially bad once you add remark or rehype plugins into the mix as Astro plugins appear to get priority and debugging those is worse as you have to restart the dev server after changes.

On the topic of working with markdown, it’s super annoying that I have to call an async render function on my markdown to get the content (or any of the custom frontmatter from remark scripts), and then it can only be used through an Astro-specific <Content /> tag. On work, I render all the items to use their direct content, and on ideas, I have to render each full blog post to generate the stripped-down content previews that get shown if I’m too lazy to write a description. Also, this specific content tag makes it effectively impossible to use markdown content inside an island, which is a shame as it would’ve significantly sped up development for the filterable card layouts.

Lastly, while using the Astro template syntax inside HTML is great, you can’t use it directly in script or style tags, even ones marked as is:raw or is:inline. There’s a workaround to use a <Fragment> such as

<Fragment set:html={`<style>.group:has(#${group}-${index}:checked) .connect-${group}-${index}{display:none}</style>`} />

but it isn’t pretty. I can understand why it might be difficult to implement this as the curly brace syntax is used for other things, but I think that there needs to be a better way of getting Astro variables into scripts or styles and letting them be used in more ways.

And the Ugly

View transitions are kind of weird, and have this huge quirk that makes them unusable for me. Unless you’re creating a PWA that prevents swiping for navigation, or if you have super obvious back buttons that make swiping unlikely, you should maybe stay away from view transitions as a backswipe on mobile does two animations – one for the swipe and one for the view transition – which can look a bit jarring. This is a known issue for all view transitions but I wanted to mention it here as it’s a big selling point for Astro.

Sometimes, the Astro formatter will change the indentation of Sass inside style tags, which is bad. I have no idea what could be causing this, and it’s frustrating as it can break the intended behavior of a style without even noticing.

Also, if you @use a file in your Sass even if the file is linked globally, and you also use Tailwind classes, any selectors from the imported file will take precedence due to Astro’s style encapsulation data tag. This is expected behavior but makes for some very weird styling bugs. On a similar note, Tailwind’s class name detection sucks, and I wish that the Tailwind plugin did a bit more to help Tailwind’s basic implementation work better by feeding the compiled files instead.

Lastly, if I save too fast, I sometimes get an error saying Internal server error: [postcss]...astro&type=style&index=0&lang.css:1:1: Unknown word. I have no idea what causes this, but I assume that saving twice after a CSS change causes some interruption, making Astro pass the entire file to PostCSS instead of just the styles. Saving again or reloading the page fixes this. As far as I can tell, no one else has had this issue.


Tailwind is pretty fantastic in my opinion. The defaults are generally sensical, development iteration is faster, especially with the editor integration, and it makes complex media queries and responsiveness way easier to write. I found that with a component-based system, Tailwind really shines as classes can be easily reused, as your HTML can get a little messy without it. In cases where it doesn’t make sense to use a component or if I want to style multiple elements at once, I used @apply which kind of defeats the purpose of utility classes at least in terms of bundling and code reuse, but is worth it for sanity. Customizing Tailwind’s configuration from the start is the best way to go, especially with custom color palettes as my portfolio uses.

I also used all of the first-party Tailwind plugins and I found them decent. My only gripe was the significant amount of customization I had to do with Tailwind Typography, but that’s a result of its opinionated approach to handling prose and personal opinion, not because it’s bad in any way.

However, Tailwind, especially when working with conditional utilities like dark or motion-safe, can get very verbose. As an example, this is the worst single line of code I’ve ever written:

@apply placeholder:opacity-0 focus:placeholder:opacity-100 motion-safe:placeholder:transition-opacity ui-border hover:border-base-150 dark:hover:border-base-850 focus:border-base-200 dark:focus:border-base-800 rounded-md w-full border-2 pt-8 placeholder-shown:pt-2 focus:pt-8 motion-safe:transition-all invalid:border-red-400 invalid:hover:border-red-600 invalid:focus:border-base-200 dark:invalid:border-red-600 dark:invalid:hover:border-red-400 dark:invalid:focus:border-base-800 invalid:placeholder-shown:border-base-100 invalid:hover:placeholder-shown:border-base-150 dark:invalid:placeholder-shown:border-base-900 dark:invalid:hover:placeholder-shown:border-base-850 bg contrast-more:focus:border-black dark:contrast-more:focus:border-paper contrast-more:hover:border-black dark:contrast-more:hover:border-paper contrast-more:placeholder-shown:invalid:border-black dark:contrast-more:placeholder-shown:invalid:border-paper contrast-more:placeholder-shown:hover:invalid:border-black dark:contrast-more:placeholder-shown:hover:invalid:border-paper contrast-more:invalid:focus:border-black dark:contrast-more:invalid:focus:border-paper

It’s such a bad line of code that Obsidian will just fully stop letting me write in this file if I label it as sass in a code block. I’ve since simplified it with custom @layer directives but it’s still pretty long.

Additionally, changing Tailwind classes typically triggers a full-page reload as the HTML content is changing instead of just changing the imported stylesheet, so it’s a slightly worse developer experience. You can of course avoid this by only using @apply but then you lose all the IDE integration, so there’s no winning.

TypeScript is better but…

TypeScript, in my humble opinion, is better than Vanilla JS. I understand why people have serious gripes with it – it compiles to untyped code, it offers a sense of security without actually giving it, types can become unreadable fast, etc. – but I think that despite being untyped at the end of the day, it ends up creating safer code by requiring more careful thought about inputs and outputs or dealing with type edge cases which is generally good. TS is especially good when working in a fully typed system, either self-made or with modern frameworks, as even though there’s still no guarantee of safety, the onus is likely to fall on the maintainer.

However, I don’t think this applies to any TypeScript that has to interface directly with the DOM. As soon as you get elements from the DOM, it’s either extremely annoying to do all necessary type safety checks or to throw all type safety away by doing a type assertion, even if you can guarantee that your assertion will be correct. Also, while TypeScript will know that document.querySelectorAll("input") will give you NodeListOf<HTMLInputElement>, but if you throw any additional qualifiers on that query selector (like a class), TS will revert to NodeListOf<Element>. Despite this, I still think that working with TypeScript even with all of the type assertions is still better even if it’s cumbersome, as it has helped me catch bugs, especially as a lot of my scripts trigger without the document contents rendered due to prefetch. On that note, please let me know if there’s a way to listen for the event of the DOM being fully rendered.

Other things that happened

Here are some other findings that don’t have a good home above:


One of the biggest advantages of Tailwind is the cross-browser font stacks are excellent – so excellent that I sometimes can’t tell which font is loading besides my custom one. Therefore, to maximize first-visit load speed, I use the built-in Fontsource SCSS mixins to set font-display: optional for all fonts. Highly recommend it for production, but I sometimes change it to block during development as Chrome doesn’t seem to cache the font or load the local options with the dev server.

Stripped Markdown

You might want a stripped version of your Markdown for content previews, but converting an MDAST (Markdown AST) to string directly strips all newlines. I instead convert the MDAST to a HAST and then convert it to text instead of string as that maintains whitespace. Also, the irony with what I said earlier about TypeScript and then using any as my function’s types is palpable.

import { toHast } from 'mdast-util-to-hast';
import { toText } from 'hast-util-to-text';
export function remarkUnformat() {
return function (tree: any, { data }: { data: any }) {
const hast = toHast(tree);
const pageText = toText(hast);
data.astro.frontmatter.unformatted = pageText.replace(/\s+/g, ' '); // Replace whitespace with single space

All Integrations


The End?

At the end of the day, I found Astro + Tailwind a breath of fresh air in terms of developer experience. Astro makes components, routing, and editing super easy and Tailwind allowed me to move much faster in styling. While I definitely had issues along the way, I’m happy to be launching this new site and I feel way more excited to be working on it for many years to come unless I redesign it all again in Gatsby.

Thoughts? Leave me feedback!


  1. Archived for transparency / a good laugh

  2. I used a custom subset of the trial version because I couldn’t for the life of me find the original

  3. anime.js and Studio Frieght’s Lenis

  4. I write all the blog posts and work entries with Obsidian, and it’s a complete coincidence that the color scheme is also from Steph Ango