Making my own theme - Part 1

Tearing out the provided design and letting my imagination run wild. Published:

Motivation

I’m a tinkerer - I like tinkering. Working with a pre-made theme, as elegant and simple as my original pick was, was not meant to last. There was always going to be something I would want done differently.

Because it’s my site, there’s also no excuse not to make it like I want it.

So out with the CSS, class names, fonts and everything that made it look nice, we’re starting (almost) from scratch. I did keep a couple things though:

  • OpenGraph / JSON-ld intgration, which I would honestly not have thought about
  • The idea of a “Prose” component, dedicated to the core of the articles

The goals

The goals were simple:

  1. As little JS as possible
  2. No (obvious) accessibility mistakes
  3. A useable yet “off-beat” design
  4. As little duplication as possible

Little Javascript

I’ll admit I like JS. It’s a guilty pleasure. But I don’t believe in shipping several megabytes of JS to serve a prettified markdown file that’s a mere few kilobytes.

That’s not good the things I care about, namely:

  1. My sanity
  2. Maintaining accessibility
  3. The battery life of visitors on mobile
  4. If FLOSS/FOSS1 enthusiasts are to spend time reading my ramblings, I cannot in good conscience force a substantial amount of non-free JavaScript on them.

Accessibility

The idea that appears to be simple, yet hides a whole world of complexity. Thankfully, not doing certain things eliminates most of the basic issues.

So here are a couple “don’t”s when it comes to accessibility:

  • Don’t mess with the focus
  • Don’t rely solely on color to communicate interactivity
  • Don’t mix shades with little contrast difference between them — even more so if color is your only indicator2
  • Don’t scramble your markup to facilitate your design

The design’s merits

On the technical side, it feels cleaner than what it was, while offering more functionality. On the “looks” side, well… Can’t argue taste, right?

…Right?

Duplication in code

Because we can easily encapsulate components with Astro, it’s easy to end up re-declaring the same stuff several times. Combine that with hard-coded values and you’ve got an entangled mess that doesn’t even get proper LSP support.

That’s not even the tooling’s fault! The cascading nature of CSS makes it inherently prone to complexity. Combine that with my learning Astro, and “new” features of CSS on the go, and there’s a beast to repeatedly tame.

So to keep the codebase somewhat clean, here’s what I need to do:

  1. Aggressively use CSS variables
  2. Constantly refactor the styles from the component scope up to the global Style Sheet
  3. Streamline the markup to avoid polluting it with design-specific elements

Quick note on removing the existing design

That was the easy part:

  • Open every file and remove the scoped CSS and class names.
  • Empty the global stylesheet
  • Thank Linus for coming up with Git

At this point, the site does not look great. It’ll look even worse once we add the CSS reset.

During this step, I made note of things I wanted to do, and what to avoid. For instance, manual modification of a font’s letter spacing strikes me as a bad idea. I would rather use a so-called “display” font than mangle one that is expected to behave a certain way.

EDITOR’S NOTE

The sections that follow are not necessarily in the exact order I did things — with so much to explore, I learned and did a bit of everything at once. Almost all decisions can be postponed, and there’s a good chance that you will revisit most of them anyway.

Defining content categories

Zod is a wonderful library

Suppose you want to have the same type of content, split into three categories. One approach would be to duplicate the frontmatter’s (metadata) structure everywhere. We don’t want that, as it clashes with stated goal number 4.

Thankfully, Zod (z) allows us to define a base schema which we can then extend.

content.config.ts
const Base = z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
slug: z.string(),
keywords: z.string(),
});
const foo = defineCollection({
schema: Base.extend({}),
});
const bar = defineCollection({
schema: Base.extend({}),
});
const baz = defineCollection({
schema: Base.extend({}),
});
export const collections = { foo, bar, baz };

And just like that, we’ve got ourselves three collections sharing the same base type. Now we need loaders to fetch the .md and .mdx files from our content directory.

A little “file pattern gotcha”

Here, try to guess what’s wrong with this:

content.config.ts
const projects = defineCollection({
loader: glob({
pattern: ["**/*.{md,mdx}", "!_index.{md,mdx}",
base: "./src/content/projects",
}),
schema: Base.extend({}),
});

Nothing! It’s perfectly valid, and matches all .md and .mdx files, except for _index.md(x). It works great.

…so why waste a chapter on it then?

Well, in my code editor — it’s NeoVim, btw — I use a plugin called “Conform” which formats the text on save. And how does it do that? – one might wonder. Well it does so by creating a temporary file while it formats your text.

And what happens to that file? — one might ask, with growing suspicion.

Astro grabs it, reads the slug, realizes “hey, that’s what I’m currently serving!” and attempts to refresh the preview, which fails, because as its name implies, the temporary file was not meant to be long-lived.

Then you get weird message saying:

A `getStaticPaths()` route pattern was matched,
but no matching static path was found for requested path

Which is indeed weird, because the file was served properly on a cold start.

My best guess is that Vite’s watcher does not honor Astro’s globbing patterns, so when Conform creates its temporary file, it is picked up and force-fed to the parser.

Then, either the deletion occurs immediately and Astro cannot find the file, or it tries finding it using its own globbing rules, which fails because they ignore the file. Whatever the case may be, the dev server chokes.

astro.config.mjs
export default defineConfig({
// snip
vite: {
server: {
watch: {
ignored: ["**/.conform.*"],
},
},
},
});

I’m not a fan of having custom rules for my IDE invade the project’s configuration, but this seemed to fix my issue for now. I’ll have to look into Conform’s configuration for a better fix.

Serving categories with their own index

As it turns out, Astro can have parameterized [directories] the same way it can have parameterized [...slug].astro files. This means that by creating:

  • pages/[collection]/
  • pages/[collection]/[...slug].astro
  • pages/[collection]/index.astro

And exporting the proper getStaticPaths() methods, we can give each of our collections its own “home” page.

src/pages/[collection]/[...slug].astro
export async function getStaticPaths() {
const collections = await Promise.all(
KnownCollections.map((kc) => getCollection(kc)),
);
const articles = collections.flatMap((c) => [...c]);
return articles.map((article) => ({
params: {
collection: article.collection as KnownCollection,
slug: article.data.slug,
},
props: article,
}));
}
src/pages/[collection]/index.astro
export async function getStaticPaths() {
const collections = KnownCollections;
return collections.map((collection) => ({
params: { collection: collection as KnownCollection },
}));
}
const { collection } = Astro.params;
const meta: CollectionEntry<"collectionIndex"> = await getEntry(
"collectionIndex",
collection,
)!;
const { Content } = await render(meta);

KnownCollections is something I derived from my content.config.ts. If you want to be able to create your own types, make sure your tsconfig.json is properly configured. Otherwise, your imports will fail despite the code being valid.

tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

collectionIndex is a separate collection, which does not render as a category by itself, but instead harvests _index.md files in every collection I have defined, and then uses that to render the introduction that sits before the entry list.

Picking fonts

Choosing what fonts to use was not easy. It’s right up there with properly naming stuff. It’s a decision that had almost always been made for me on projects - and in UI intensive applications, you usually end-up with something like this:

body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}

Read more at css-tricks.com

Which does the job, but is a little disappointing - if not outright inconvenient for long-form reading. With that in mind, I went looking for fonts.

Where to get fonts?

Be careful which fonts you use! And by that I mean check the license and make sure you are allowed to use the font.

Many sites host fonts for download / embedding, with various preview options. Here are a couple:

Some of them may be supported by Astro’s built-in Font Provider. I chose to download the fonts I use here, as not to depend on an external source, be it at runtime or build time.

Please note that the same font from different providers may behave differently, as the font may have been subsetted3 before the upload - make sure to test the characters and glyphs you want to use.

Note that embedding fonts directly from providers may enable tracking of your users, even if you don’t include ads in your site. While hosting them requires a bit more bandwidth, a sensible caching policy can really soften the blow.

Serif vs Sans / Legibility vs Readability

Serif fonts are what we usually see on printed media. They look good on paper, but on screens, rendering the small details may lead to blurry text.

Sans-serif fonts solve that problem, but the trade-off is that characters may be less distinguishable from one another, which may hurt legibility.

The point of this article is not to do a deep dive on this, so here are a couple resources I used when researching this topic:

  • Serif vs. Sans-Serif for the Web: The Definitive Comparison (fontfyi.com)
  • Legibility vs Readability: What’s the Difference? (typetype.org)
  • Hyperlegible: an approach to accessible type design (youtube.com)

Light? Dark? Both?

As of writing, light theme is a work-in-progress here — there is quite a bit of thinking and doing involved.

Light themes are the default almost everywhere. They tend to look clean and professional, but working with color while meeting WCAG contrast requirements can be tricky.

Dark themes allow for a bit more fun, but have challenges of their own, for instance:

  • Long-form reading can be less comfortable for a variety of reasons
  • White text over dark background can create a “bloom” effect in people with astigmatism (especially with #fff text on #000 background).
  • Bold text of the same color as the copy Helveticas less visible compared to black-on-white designs

And switching from one to the other is not as simple as swapping colors and inverting lightness values, even with oklch.

Working with colors and contrast

On a light background, getting a visible, distinguishable tint while maintaining an acceptable contrast ratio severely limits the options. This, in my (admittedly limited) experience, means that while dark mode can use different colors for text, light mode lends itself more to using different color for surfaces.

There are a few useful tools we can use, chief among them is CSS variables paired with the light-dark (MDN) function.

While it does not solve overarching design concerns, at least it limits the complexity of the code.

Media queries to the rescue

Media queries are great, as they let us ask the client for hints as to what the user prefers. Here are a couple examples:

@media (hover: hover) {
/* The device supports :hover, like a desktop */
}
@media (prefers-reduced-motion: reduce) {
/* The user would rather the page not jerk around constantly */
}
@media (prefers-color-scheme: dark) {
/* The user prefers to use dark themes */
}

The last one is useful for this chapter, but there’s a slight downside: by default, a device that wasn’t configured for dark mode will default to light. Because there’s no support for a third option like “no-preference”, if you include this media query, many will default to light theme.

EDITOR’S NOTE

This is not a bad thing — it should not be the user’s responsibility to manually tune every website and manage lists of “X mode allowed” / “X mode only” sites.

Another option I have seen is switches that toggle between:

  • Dark
  • Light
  • Sometimes a third option: system default

I don’t like this for several reasons:

  • It forces the user to look for a button that isn’t always clearly visible, and wastes valuable space
  • If using the 3 options, the button does not always clearly indicate what is happening
  • On sites that I have seen do this, light theme usually really bright, leading to a “flash-bang” effect
  • It requires storing user preferences, which requires a consent banner4

What about the tone?

Suppose you end up making two somewhat different-looking themes for your site:

  • A light one, clean, that clearly looks professional
  • A dark one, with little quirks and effects, a more personal touch.

The content of the site does not change, but the expectation of your users may. I’m not sure how to address that, other than making sure your content sets the tone right away - regardless of how it is presented to the users.

Leveraging CSS

Variables are great and you need more of them

While working on a component, I will hard-code everything “new” in the design in the scoped CSS (that’s the CSS that sits in the component, between the <style>…</style> tags) for instance:

  • color values
  • font sizes
  • spacing (paddings, margins…)

But only if I don’t already have what I want in a variable. I decided early that the default space / gap would be 1rem, put that in a variable, and re-used it everywhere - including calc() functions.

Once I am satisfied with the result, I streamline and extract what should be reused. A typical duplication trap would be to have an accent class that changes the color, while having some tags with local a style doing the same thing:

.accent {
color: var(--accent-color);
}
header {
color: var(--accent-color);
}

While the custom class has semantic meaning and can be overridden globally, setting it on the tag may come back to bite you if you decide that in another color scheme, it’s actually the background and not the color that needs changing.

EDITOR’S NOTE

I am by no means a CSS expert, this comes from my own experience working mostly with “vanilla” CSS and / or tailwind.

While I can see a lot of value in utility classes, I find that they don’t really lend themselves to making profound changes. For that, using Tailwind’s @apply directive to extract them under a more “semantic” naming seems to be a better option.

Variables can be overridden

This feature was new to me, and it is mind-blowing. Here’s a sample:

stylesheet
:root {
--accent-color: orange; /* Defines the variable */
}
.accent {
color: var(--accent-color); /* Uses it */
}
.blue {
--accent-color: blue; /* Overrides it for all children */
}
Markup
<article>
<header class="accent">This header is orange</header>
</article>
<article class="blue">
<header class="accent">This header is blue</header>
</article>

This is what I have used to give each category its own “main” color. It’s really efficient. Whether that looks good is another issue entirely.

Now, combine that with spacing variables (preferably using variable units such as rem) you can easily transform a whole section by merely overriding a few variables in the scope.

Footnotes

  1. Remember these guys? https://www.gnu.org/philosophy/floss-and-foss.en.html

  2. In the words of the CEO of HTMX (x.com)

  3. Read more about Font subsetting at fonts.google.com

  4. This is considered functional cookie, not essential — which means you need a consent banner. Whether accessibility features are optional may be up for debate, but let’s be honest: there are other, non intrusive options to support the users. Also, from what I gather, using local storage does not get you off the hook.