Because I like fiddling with websites, I set myself some challenges when building this one:
- I should do all the design myself, from scratch.
- It should be approximately as fast as possible.
- Everything should work without Javascript.
This page collects all the tricks I’ve used to make that work.
Design
In college, I noticed that one of my roommates—also a math major who hadn’t previously displayed any artistic inclinations—had a beautiful personal site.
“Wow, how did you get it to look so good?” I asked. “Teach me your web design secrets!”
“I’m terrible at web design and I don’t have a secret,” he said. “You just have to be really obsessive about it.”
(I think about this exchange a lot.)
Performance
I learned a lot from living in Jigjiga, Ethiopia, where I often had 10-second ping times and 10%+ packet loss. On this type of connection much of the web is unusable. Fortunately, as a site designer, by caring a bit it’s possible to do much better. Things that help with this:
- Keeping pages small. As of this writing, the entire front page currently weighs under 6kb gzipped. This isn’t hard to achieve if you avoid heavy dependencies like CSS or Javascript libraries, or raster images, or, god forbid, fonts. (I’ve indulged myself a bit with the footer image, which is another 9kb gzipped—it’s small for an image because it’s a simple SVG, whose filesize scales with the number of shapes in the image rather than the dimensions on the page.)
- Using a CDN. I use CloudFront, mostly because it integrates nicely with S3 (where I actually upload the files) and AWS Athena/QuickSight for analytics (see below). By distributing my site to many “points of presence” around the world, CloudFront makes it a lot faster to load from e.g. Europe than it would be if I hosted it in a single US location.
- Inlining small resources. If I used external stylesheets/scripts, it would be more cache-friendly—visitors loading their second or third page wouldn’t have to download the same content again. But my CSS and JS are tiny, only a few kilobytes, so the savings are small. Meanwhile, this would slow down first-time visitors because the browser would need extra round-trips to fetch the resources. Most visitors to this website only load a single page, so it’s more important to optimize that case.
I use a self-hosted Mautic instance to manage my mailing list, although I don’t necessarily recommend this to anyone else.
I started doing it so that I could do “drip” style email campaigns, like the one on Programming essays I think about a lot where you can get the essays emailed to you, one per week. I also started sending new subscribers a drip campaign of the best posts from the archives.
Editing emails in Mautic is kind of annoying, so I ended up building out some site infrastructure that let me generate the email HTML with Hugo (my static site generator) and then a small Python script that syncs them with Mautic. The other advantage of this is that if I update any of the posts in e.g. the “best-of” list, they automatically get included in the
Email-related things that I do recommend to others:
Email signup form on the homepage. I get about 50% of mailing list signups through this form rather than through the end-of-post signup. Mailing lists are the best way to build a direct, long-term relationship with readers, so I strongly recommend maintaining one.
When someone signs up, I send an automated “thanks for subscribing” email asking people to reply telling me about themselves and what they’d like to read more about. This gets a lot of responses, sometimes starts interesting conversations, and is great fodder for future blog posts. (Plus it’s a big ego boost, since the people who respond are usually the ones who liked the posts the most!)
Sidenotes
My sidenotes are inspired by tufte-css.✻ They look like this! They have a couple important ingredients.
First, a pure-CSS way of making the sidenotes lay themselves out in the right margin, without overlapping:
.sn-text {
float: right;
width: 200px;
margin-right: -250px;
clear: right;
margin-top: 10px;
}
On mobile I can’t use margins, so instead, the sidenotes are hidden by default and the sidenote indicator symbol becomes clickable. This uses the hidden-checkbox trick described below. A different bit of CSS causes sidenotes to appear in a full-width box that interrupts the paragraph flow:
@media screen and (max-width: 1180px) {
.sn-wrapper {
display: none;
}
.sn-text {
float: unset !important;
}
input:checked ~ .sn-wrapper {
display: block;
float: left;
clear: both;
width: 100%;
margin: 10px 0;
padding-left: 37px;
}
sup.sidenote label {
color: #69e;
color: var(--color-link);
text-decoration: underline;
cursor: pointer;
}
}
To use the hidden-checkbox trick, I needed to put the sidenote body inside a <sup>
. To undo all the styling this applied, I found the little-known all: initial
CSS rule, which I apply to a style-root
class. (I then have to re-apply all my base styles to style-root
as well as html
.)
.style-root {
/* must go first so that all: initial
* doesn't clobber the below */
all: initial;
}
html, .style-root {
background: #fff;
background: var(--color-background);
/* etc. */
}
Finally, a Hugo shortcode actually generates the necessary HTML using some super janky templating:
{{- /* Global sidenoteNum counter */ -}}
{{- if eq ($.Page.Scratch.Get "sidenoteNum") nil -}}
{{- $.Page.Scratch.Set "sidenoteNum" 0 -}}
{{- else -}}
{{- $.Page.Scratch.Set "sidenoteNum" (add ($.Page.Scratch.Get "sidenoteNum") 1) -}}
{{- end -}}
{{- /* Compute the symbol to use for the sidenote reference */ -}}
{{- $sidenoteNum := $.Page.Scratch.Get "sidenoteNum" -}}
{{- $sidenoteChars := slice "✻" "†" "‡" "§" "‖" "¶" -}}
{{- $char := index $sidenoteChars (mod $sidenoteNum (len $sidenoteChars)) -}}
{{- $numChars := div $sidenoteNum (len $sidenoteChars) | add 1 -}}
{{- $sidenoteSymbol := $char | strings.Repeat $numChars -}}
<sup class="sidenote">{{- /* */ -}}
<label for="sn{{ $sidenoteNum }}">
{{- $sidenoteSymbol -}}
</label>{{- /* */ -}}
<input
id="sn{{ $sidenoteNum }}"
type="checkbox"
style="display: none"
/>{{- /* */ -}}
<span class="style-root sn-wrapper">{{- /* */ -}}
<span class="sn-text">{{- /* */ -}}
<sup>{{- $sidenoteSymbol -}}</sup>
{{ .Inner -}}
</span>{{- /* */ -}}
</span>{{- /* */ -}}
</sup>{{- /* */ -}}
(The {{- /* */ -}}
are a Hugo templating hack to strip whitespace.)
Analytics
I wanted some basic analytics for my site that met the following criteria:
Zero bloat. The analytics should run off my visitor logs, without needing to change the site itself in any way.
CDN compatible. The analytics should consume the logs in whatever format my
I searched around for a while before realizing that one of AWS’s many, many database offerings is really well-suited to this: Athena. Athena lets you query “databases” that are made up of many CSV files (like CloudFront logs) inside an S3 bucket. Even better, AWS also has a built-in tool for creating dashboards from various data sources including Athena, called Quicksight.
I realized this would let me do the whole thing without running any infrastructure myself: I could create an Athena “table” directly from the Cloudfront logs that AWS was already archiving for me, then create a Quicksight dashboard that queried the table, and I’d be in business.
AWS already has instructions for setting up Athena to query Cloudfront, so it ended up being about a 1-hour project to build a simple dashboard:
- Create the Athena table, per the instructions above.
- I created a derived “view” that included some extra columns that were useful for analytics, like
is_bot
andis_internal_referrer
. (You can read the SQL if curious.) - I moved my old Cloudfront logs to a different directory that wasn’t included in the Athena table, because I had 3+ years of logs and it was making my queries super slow.
- I set up Quicksight and built some charts.
I’m pretty happy with this setup—I have a dashboard for my common queries, and if I want to do any sort of complicated analysis, I can write ad hoc SQL to do it myself. Quicksight seemed buggy enough that I wouldn’t use it for anything serious, but it’s fine for my purposes and doesn’t cost anything for personal use. (For professional use, at Wave we use Periscope Data which is a better but pricier version of the same thing, and it’s been relatively great.)
One minor downside is that the dashboard takes several seconds to load due to the Athena queries being slow. I don’t mind this because I only look at it a couple times a week. If I cared a lot, there’s some stuff I could do with partitioning to make it faster at the expense of adding another moving part (a Lambda function to rename the Cloudfront log dumps).
Dark mode
There’s this cool thing called CSS variables that lets you define strings that represent colors. You can combine this with the prefers-color-scheme: dark
media query to build a [DRY][] dark mode:
html {
--color-background: #fff;
}
@media screen and (prefers-color-scheme: dark) {
html {
--color-background: #222;
}
}
html, .style-root {
background: #fff; /* backwards compat */
background: var(--color-background);
}
The only other dark mode trick I have is a couple ways of making images less intrusive. By default, I don’t change anything, but with the right CSS class I can dim them (best for e.g. photos) or invert them (best for black and white):
@media screen and (prefers-color-scheme: dark) {
.dark-invert {
filter: invert(80%);
}
.dark-darken {
filter: brightness(70%);
}
}
Javascript-less buttons
There are two parts of this site that are hard to make work without Javascript.
The first is toggle buttons, like the “reply” buttons in comment forms that disclose a comment box. For these, I use the “hidden checkbox” CSS trick, where the button is a <label>
for a hidden checkbox that stores the toggled state. I can then use the :checked
pseudoclass to toggle CSS. HTML:
<div class="comment-reply">
<input type="checkbox"
id="reply-toggle-{{.id}}"
class="reply-toggle"
>
<label for="reply-toggle-{{.id}}">
<a class="reply-toggle-label ui-sans">reply</a>
</label>
<!-- comment form goes here -->
</div>
CSS:
.reply-toggle-label {
line-height: 200%;
cursor: pointer !important;
}
.reply-toggle {
position: absolute;
opacity: 0;
}
.reply-toggle:focus ~ label {
outline: #666 auto 5px;
outline-color: var(--color-secondary);
outline-color: -webkit-focus-ring-color;
}
.reply-toggle:not(:checked) ~ .ui-form.reply {
display: none;
}
.reply-toggle:not(:checked) ~ label > .reply-toggle-label::before {
content: "\25b7 ";
}
.reply-toggle-label::before {
content: "\25bd ";
}
Javascript-less forms
The other tricky thing to do without Javascript is forms that don’t require navigating away from the page. I need this for mailing list signups, and for comments. It turns out that you can accomplish this by making the form target
an iframe
:
<form
class="ui-form {{.classes}}"
action="{{.action}}"
target="{{.iframe_name}}"
method="post"
onsubmit="onSubmit(this)"
>
<!-- Form contents go here -->
<iframe
class="form-noscript"
name="{{.iframe_name}}"
onload="onIframeLoad(this)"></iframe>
</form>
The iframe can serve as the feedback indicator, redirecting to a page that says something like “your comment was submitted!” after processing the request.
Unfortunately, it doesn’t look super great. In particular, the iframe needs to be visible on the initial page load—without Javascript, there’s no way (that I know of) to dynamically show it only once the “submit” button is clicked.
For people who do have JS enabled, I’d like to do something a bit nicer. First, I hide the iframe (or rather, show it only if JS is disabled):
<noscript><style>
.ui-form iframe {
display: block !important;
}
</style></noscript>
Then I use a couple of JS event handlers to apply CSS classes to the form depending on its state:
function onSubmit(form) {
var iframeName = form.target;
var iframe = document.getElementsByName(iframeName);
form.classList.add('submitting');
for (var i = 0; i < form.elements.length; i++) {
form.elements[i].readOnly = true;
}
}
function onIframeLoad(iframe) {
// Fires when the form action is finished, but also on initial page load
// (when we load the noscript page). We only want to change CSS if the
// form was submitted.
var form = document.querySelector('[target="' + iframe.name + '"]');
// record that loading finished... but only if the form has been submitted
if (form.classList.contains('submitting')) {
form.classList.remove('submitting');
form.classList.add('submitted');
}
}
Stack
benkuhn.net is made with the Hugo static site generator with a custom theme. The footer art is by Eve Bigaj. The site is hosted by AWS S3/CloudFront and deployed via Github Actions. Analytics (backend only, no tracking) use AWS Athena and Quicksight (writeup). Comments are managed by Staticman hosted on Digital Ocean; mailing list signups go through AWS Lambda + Mautic.