Using Storybook with Nunjucks components in Eleventy

When I’m working on an Eleventy site, I typically use Nunjucks macros to build components on the pre-rendered side of things. In particular, I’ve grown really fond of Trys Mudford’s encapsulated component pattern, which makes importing and calling macros a nice and tidy affair:

permalink: /index.html
{%- from "components/component.njk" import component -%}

<h1>{{ site.title }}</h1>

{{ component( "button", {
    label: "Hello from Eleventy",
    primary: true,
    size: "large"
}) }}

Macro-based components lend themselves to an idea I’ve been circling around for a little while: an internal development “library” of reference pages, built right into the project using Eleventy and skipped during production builds.

I envisioned using the library to develop components and utility classes separately from the site where they’d eventually be used, and it worked pretty well:

So far, at least, building components and utilities in isolation first and then using them elsewhere has helped ensure there aren't any contextual dependencies or assumptions that make things work one place but break in others.

Still, I found that building and maintaining the library’s infrastructure took almost as much time and effort as the site itself 🥴

Fortunately, there are some excellent tools purpose-built to address this exact need.


Storybook is a browser-based tool for developing front-end components in isolation, testing various states and variations of those components, and combining them in contexts as they’re expected to exist in a final product. In short, it’s the library I’ve been dreaming of—and more.

The introduction page captures what I see as its core strength:

Storybook helps you build UI components in isolation from your app's business logic, data, and context. That makes it easy to develop hard-to-reach states. Save these UI states as stories to revisit during development, testing, or QA.

Music to my ears!

If you’re not familiar with Storybook, the UI looks like this:

Storybook interface showing a button component labeled 'Button with a custom label'

Each component has one or more “stories” that capture a specific state, like the Primary, Secondary, Large, and Small variations of the Button component above.

Moreover, a story can accept arguments that are used to define a component’s state via manipulable attributes—like a button label, or a two-state modifier, or a range of size classes, etc. These can be toggled and tweaked in real-time with Storybook’s UI, allowing a developer or tester to move a component into new states quickly and easily.

This maps so closely to how macro-based Nunjucks components are invoked that it seems like the two would be a natural fit! Storybook doesn’t support either Eleventy or Nunjucks directly, though, and for a while I’ve assumed that was a non-starter.

But after spending some time wrestling with webpack, I discovered that by combining Storybook’s built-in HTML framework with some additional configuration we can get everything playing together nicely.

If you’re a step-by-step learner, the following guide will walk you through the process of adding Storybook to your Eleventy project and configuring things to work with macro-based Nunjucks components. (If you prefer to poke through the source of a final product, I’ve put an example repo up on GitHub.)

Adding Storybook to your project

Before starting, it’s important to note this guide and the sample project use the following structure for Nunjucks components and their Storybook files:

├─ _includes/
│  ├─ components/
│  │  ├─ button/
│  │  │  ├─ button.css
│  │  │  ├─ button.js
│  │  │  ├─ button.njk
│  │  │  └─ button.stories.js
│  │  ├─ header/

As in Storybook’s default examples, the component’s styles are defined using a standalone stylesheet that lives alongside the Nunjucks macro.

Webpack handles this automatically for Storybook, but your Eleventy project will need method for rounding up stylesheets spread over individual component folders and plopping them in the right places. (If you don’t currently have a solution for this, I’ve provided a couple of approaches at the end of this guide.)

If that sounds reasonable, let’s get crackin’!

Install dependencies

First, install @storybook/cli as a dev dependency in your Eleventy project:

npm install --save-dev @storybook/cli

Next, run the Storybook setup command at the root of your project and specify that we want to use the plain old HTML framework:

npx sb init --type html

Finally, install simple-nunjucks-loader@2, a Nunjucks loader for webpack, as a dev dependency:

npm install --save-dev simple-nunjucks-loader@2

The latest major version of simple-nunjucks-loader seems to be incompatible with webpack 4, the version Storybook currently uses, but downgrading to simple-nunjucks-loader@2 works great 🌟

Configuring Storybook

With dependencies installed, there’s one more detail to take care of before getting our Nunjucks component into Storybook.

Register the Nunjucks loader

We need to tell Storybook’s webpack instance to use the Nunjucks loader for any files that have a .njk file extension. (Otherwise, when it comes time to write stories, webpack won’t know what to do with our macro template files.)

Open .storybook/main.js and add the following as a top-level property of the module.exports object:

"webpackFinal": (config) => {
        test: /\.njk$/,
        use: [
                loader: 'simple-nunjucks-loader',

    // Return the altered config
    return config;

Tidy up default stories

Storybook setup creates a src/stories folder with some default examples. If you don’t plan to create stories outside individual components in Eleventy’s _includes folder or if they should live elsewhere, you can safely move stories anywhere in src or delete it altogether.

Creating a component story

Now that Storybook is installed and configured to support Nunjucks templates, let’s recreate an abridged version of the default Button example component and its stories.

Create the component styles

First, create a button folder inside src/_includes/components, then paste the following into a new file named button.css:

.storybook-button {
      font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
      font-weight: 700;
      border: 0;
      border-radius: 3em;
      cursor: pointer;
      display: inline-block;
      line-height: 1;
    .storybook-button--primary {
      color: white;
      background-color: #1ea7fd;
    .storybook-button--secondary {
      color: #333;
      background-color: transparent;
      box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
    .storybook-button--small {
      font-size: 12px;
      padding: 10px 16px;
    .storybook-button--medium {
      font-size: 14px;
      padding: 11px 20px;
    .storybook-button--large {
      font-size: 16px;
      padding: 12px 24px;

This CSS was taken directly from Storybook’s Button component example, without any permission whatsoever 😇

Create the Nunjucks macro

Next, paste the following into a new file button.njk:

 # @prop {string} label
 # @prop {boolean} [primary=false]
 # @prop {string} [size="medium"]
{% macro render( props ) %}
{%- set mode = "primary" if props.primary else "secondary" -%}
<button class="storybook-button storybook-button--{{ props.size }} storybook-button--{{ mode }}">
    {{ props.label }}
{% endmacro %}

This gives us a perfectly functional Nunjucks macro that Eleventy could import, either directly:

{% import "components/button/button.njk" as button %}

{{ button.render({
    label: "Hello from Eleventy",
    primary: true,
    size: "large"
}) }}

or using the encapsulated component pattern:

{%- from "components/component.njk" import component -%}

{{ component( "button", {
    label: "Hello from Eleventy",
    primary: true,
    size: "large"
}) }}

There’s a medium-sized catch, however! 😬

When rendering a Nunjucks template, macros must be called explicitly; simply including or rendering button.njk in its current state would result in empty output.

This isn’t an issue in Eleventy — in fact, it’s one of the reasons Nunjucks macros work so well for building and injecting reusable components! Unless I’m mistaken, though, the simple-nunjucks-loader package we’re using to add Nunjucks support to webpack doesn’t provide a way to call macros 😅

You’ve read too far and for too long for me to throw up my hands now and say, “Sorry, Nunjucks and Storybook don’t work together actually!” and try to Homer-bush my way out of here.

So instead, let’s add a little block below the render() macro in button.njk:

{% if storybookArgs %}
    {{ render( storybookArgs ) }}
{% endif %}

This tells Nunjucks, “Listen. If you encounter a context variable named storybookArgs when you’re rendering button.njk, go ahead and call the render() macro automatically and pass that variable as its props object. Otherwise, just go about your business and don’t do anything special.”

By adding an automatic macro invocation that’s dependent on the presence of the storybookArgs context variable, our Nunjucks template can continue to be imported and rendered in Eleventy as usual without any change in behavior, while also giving us a handhold when it comes time to render the component in Storybook.

🎈 The name of this context variable can be anything, just make sure to set it to something that wouldn’t otherwise be defined in your project’s data cascade.

Create the component rendering script

Next up, create button.js alongside the other component files inside ./src/_includes/components/button, and paste the following:

import './button.css';
import renderButton from './button.njk'

 * @param {Object} args
 * @param {string} args.label
 * @param {boolean} [args.primary]
 * @param {string} [args.size="medium"]
export const createButton = (args = {}) =>
    args.size = args.size || "medium";

    // Setting a `storybookArgs` context variable causes the render() macro to
    // be called automatically 📕✨
    return renderButton({
        storybookArgs: args

This file is the Nunjucks counterpart to the stories/Button.js example file generated during Storybook setup, and it does a few different things.

First, it tells webpack to import our component’s stylesheet. Nice.

It also imports a function we’re calling renderButton that’s used to render the contents of button.njk, made possible by simple-nunjucks-loader.

Finally, the script exports a function createButton for use by the stories script we’ll set up next. The function accepts Storybook arguments and passes them to the Nunjucks template for rendering. If you look closely, we’re setting a context object with a top-level property called storybookArgs!

Paired with the {% if storybookArgs %} block we tacked on in button.njk, it’s this property that kicks off automatic macro invocation and allows us to use the same Nunjucks template file in both Eleventy and Storybook

Create the stories script

Finally—at long last!—create button.stories.js and paste the following:

import {createButton} from './button';

export default {
    title: 'Nunjucks Button',

    // More on argTypes:
    argTypes: {
        label: { control: 'text' },
        primary: { control: 'boolean' },
        size: {
          control: { type: 'select' },
          options: ['small', 'medium', 'large'],

const Template = ({ label, ...args }) => {
    return createButton({ label, ...args });

export const Primary = Template.bind({});
Primary.args = {
    primary: true,
    label: 'Button',

export const Secondary = Template.bind({});
Secondary.args = {
    label: 'Button',

export const Large = Template.bind({});
Large.args = {
    size: 'large',
    label: 'Button',

export const Small = Template.bind({});
Small.args = {
    size: 'small',
    label: 'Button',

Also taken rather unceremoniously from Storybook’s Button component example.

This script imports the createButton function from button.js, defines argument types for use in Storybook, and exports a handful of stories that capture different states for our Button component.

Launch Storybook

Here we go! Run the following command at the root of your Eleventy project:

npm run storybook

After some initial webpacking, Storybook launches with a big, beautiful blue button piped in from the same Nunjucks template that we use as a reusable component in our project:

Storybook interface showing a button component labeled 'Nunjucks Button'

Best of all, we still get the same reactivity that really makes Storybook shine: toggling the primary control switches between button styles, changing the label value updates the rendered component automatically, and so on.

So that’s it! Storybook and macro-based Nunjucks components built for Eleventy, together at last 📕🎈🐀🎆

If you get jammed up for some reason—or if you just find this useful, or come up with a cool variation or improvement—give me a holler! I’d love to hear from you.

As a reminder, an example project is up on GitHub for you to peruse. And if you want to peek at the Storybook we built here, a static copy is available to play with up on Netlify.

Appendix: Stylesheet Roundup

Here are a couple of different approaches you might consider for including component stylesheets in your site.

Inline CSS

If you’re using Inline Minified CSS—a great approach for a small site without a bunch of CSS—you can include your component styles by adding them below any existing stylesheets:

<!-- capture the CSS content as a Nunjucks variable -->
{% set css %}
    {% include "sample.css" %}
    {% include "components/button/button.css" %}
{% endset %}

  {{ css | safe }}

This requires manually adding a stylesheet each time a new component is added, which might get a little fiddly after a while.

Global styles data

A similar approach—which is potentially kind of weird, admittedly!—combines the simplicity of inline minified CSS with the power of Eleventy data.

First, install both clean-css and glob (or any other CSS minifying and globbing packages you might prefer):

npm install --save clean-css glob

Next, create styles.js in your _data folder and add the following:

const CleanCSS = require( "clean-css" );
const glob = require( "glob" );

 * A loose interpretation of CUBE CSS, which is awesome
const sources = [
        name: "global",
        path: "./src/styles/global.css",
        name: "composition",
        path: "./src/styles/composition.css",
        name: "utilities",
        path: "./src/styles/utilities.css",
        name: "blocks",
        path: glob.sync( "./src/_includes/components/**/*.css" ),

const cleanCss = new CleanCSS(
    {} // See

const styles = {};

sources.forEach( source =>
    let sourcePath = Array.isArray( source.path ) ? source.path : [source.path];
    let result = cleanCss.minify( sourcePath )

    if( result.warnings.length > 0 )
        console.log( result.warnings );

    if( result.errors.length > 0 )
        throw new Error( result.errors.join( "\n" ) );

    styles[] = result.styles;

module.exports = styles;

The sources array defines stylesheet “scopes” in the order they should be imported. The example above loosely follows the CUBE CSS, which I really love, but you could use a simpler source set depending on your setup:

const sources = [
        name: "global",
        path: "./src/styles/sample.css",
        name: "blocks",
        path: glob.sync( "./src/_includes/components/**/*.css" ),

In either case, using a glob pattern automatically adds any stylesheets found in src/_includes/components, eliminating the need to manually manage stylesheets any time a new component is created or removed.

Finally, the script passes each path or glob pattern to CleanCSS for minification, then adds the resulting styles by name as top-level properties of a global data object called styles:

    global: 'body{background-color:var(--background-color)}a{color:currentcolor} [...]',
    composition: '.container{max-width:var(--container-size)}.stack-sm>*+*{margin-top:var(--stack-sm-size)} [...]',
    utilities: '.center{margin-left:auto;margin-right:auto}',
    blocks: `.page{color:var(--tint-900);.storybook-button--large{font-size:16px;padding:12px 24px} [...]`

Like the previous inline CSS approach, we can inject styles using the global data object styles:

    {{ | safe }}
    {{ styles.composition | safe }}
    {{ styles.utilities | safe }}
    {{ styles.blocks | safe }}

Separating by “scope” on the styles object lets us plop specific styles where they should be included. For example, maybe we want to include global, composition, and utilities style in our inline CSS:

    {{ | safe }}
    {{ styles.composition | safe }}
    {{ styles.utilities | safe }}

and build an external stylesheet for components and other blocks:

permalink: main.css
{{ styles.blocks | safe }}

Have a different approach to managing component styles in your Eleventy project? I’m always fussing with this part, and I’d love to hear what people are cooking up!

Spell Check

My wife Emily and I are big fans of the Spelling Bee, a daily word-finding puzzle from the New York Times.

Since the early days of the pandemic we’ve built a habit of playing together almost every morning. Our routine is to find as many words as we can individually, then over coffee and breakfast we’ll compare words and go for Genius (or, if we’ve gotten there already, try for the elusive Queen Bee).

There are days, though, when we need a little assist; a push in the right direction. A plethora of tools and resources already exist to help with this, including the official grid published daily in the Spelling Bee Forum. We’ve had some success with these, but find it a little cumbersome to switch back and forth between the puzzle and the external grid.

So, like a good middle-aged web nerd, I built Spell Check*, a bookmarklet that fetches the official grid and then adds a progress grid to the game itself:

Spell Check being used on a mobile device, showing a grid of remaining words for each letter-length combination.

No more bouncing between sites! Everything is in one place and totals are updated automatically, showing only the number of remaining words. Even better, you don’t have to strain to tell whether a word like ILLICITLY has 7 or 8 letters ever again. (It’s 10, actually. (Or is it 9? 😜)) Just work your word magic like usual, then do a quick tap-tap to check how close you are to the crown.

If you play the Spelling Bee, I’d be thrilled if you give Spell Check a whirl! Any modern browser will do, on just about any platform. (If you run into issues getting set up or using the bookmarklet, please let me know!)

*I asked Emily what it should be called, and without missing a beat she said, “Spell Check.” It’s perfect 💚

Lynn Fisher’s annual refresh

It’s time for one of my favorite annual traditions on the web, Lynn Fisher’s portfolio refresh.

As always, be sure to resize your browser (and zoom out while you’re at it! 👀) Every square pixel is a delight.

Responsive Art

Over the holiday weekend I started playing with an idea that’s been bouncing around my head for a little while: could you use CSS Grid to draw “pixels” on a resizable, responsive canvas? (“Canvas” as in 🎨, not as in <canvas> ☺️)

Sure! Why not? After all, that’s precisely what CSS Grid is good at (especially when auto-fill and minmax are in the mix): laying out box-shaped elements on uniform tracks. But I ended up making something I think is far more beautiful and engaging than I imagined, that embraces the nature of CSS; something you might call “responsive art.”

A responsive canvas

Here’s an example of my initial idea — a resizable grid that contains 21 grid elements (“pixels,” for lack of a better term), all with the same dark background color:

If you’re using a desktop browser go ahead and grab the lower right corner and resize this canvas to be narrower or wider

By using grid-template-columns: repeat(auto-fill, minmax(...)), resizing the canvas allows the “pixels” to grow and shrink accordingly. Once the grid can no longer accommodate the current number of elements in a row—or, conversely, if the row width allows for another element to squeeze in while still maintaining the minimum element width—the grid elements reflow into a new layout.

Here’s a snippet of what the relevant CSS looks like:

.canvas {
	--pixel-min: clamp( 30px, 6vmax, 60px );

	display: grid;
	grid-template-columns: repeat(
		minmax( var( --pixel-min ), 1fr )
	overflow-x: hidden;
	resize: horizontal;

.canvas > * {
	background-color: #000;
	padding-bottom: 100%;

It’s a little tricky to see what exactly is happening when all the pixels share the same background color, so let’s alternate between light and dark to get a better sense of the layout.

To achieve this we could figure out a way to style each pixel element individually—maybe by creating a light or dark class and adding it to the appropriate elements, or we could use an :nth-child pseudo-class to select every other element with a single CSS rule:

.canvas > *:nth-child(2n) {
	--color: #000;
The same grid figure as above, but with alternating light and dark backgrounds

Better! Simple and straightforward to implement, and we can see that the canvas’s elements are reflowing the way we want. (Also, you might notice there’s some fun stuff happening at different widths, but let’s come back to that in just a bit... 😉)

For some extra flavor, let’s toss a proper palette into the mix using successively greater :nth-child values:

.canvas > * {
	--color: #a6035d;
.canvas > *:nth-child(2n) {
	--color: #f27405;
.canvas > *:nth-child(3n) {
	--color: #d91e41;
A brightly colored canvas with pixels in magenta, orange, and red

Now we’re getting somewhere! But even though it’s more colorful it still isn’t particularly artful. Adding a bunch of extra pixel elements might get us a little closer to the kind of engaging design I was originally hoping to achieve, but my gut tells me it would just end up a muddy mess of colors.

The :nth-child selector approach got me thinking, though: would extending that to other properties beyond just background-color produce anything interesting, especially at the intersections of multiple rules?

Embracing the cascade

With our colors already defined, let’s see what happens as we start adding additional rules to give shape to our pixels.

First, let’s add a rounded corner to the top left corner of every element on the canvas:

.canvas > * {
	border-top-left-radius: 100%;
The same brightly colored canvas, with every pixel rounded at the top left corner

Kind of neat, but not much more interesting than the previous iteration of colored squares. Let’s round the top right corner on every other pixel and see what happens:

.canvas > *:nth-child(2n) {
	border-radius: 0;
	border-top-right-radius: 100%;
The same brightly colored canvas, with every second pixel rounded at the top right corner

Things are getting a little more interesting! A row of scallops in varying colors is starting to feel more art-y, and depending on the width of the canvas you either get uniform distribution or a nice offset between rows.

(Adding border-radius: 0 to subsequent :nth-child rules to reset and prevent ever-rounder pixels is an aesthetic choice I made after playing around a bit while building this example, but you could absolutely let every previous rule continue to cascade for weirder effects.)

You can probably guess where we’re headed next ☺️ Let’s round the bottom left corner of every third pixel:

.canvas > *:nth-child(3n) {
	border-radius: 0;
	border-bottom-left-radius: 100%;
The same brightly colored canvas, with every third pixel rounded at the bottom left corner

Wowowow! By adding just one more rule, we’ve jumped pretty far outside the strict visual boundaries of our grid to form new shapes woven together by multiple pixels.

Another exciting change with the new rule is that the design starts to change pretty radically at different widths. Try resizing this canvas down to three columns and then up to eleven. They’re obviously all related, but—to me, at least—they evoke very different feelings.

Okay, let’s round out our set by adding a fourth and final rule:

.canvas > *:nth-child(4n) {
	border-radius: 0;
	border-bottom-right-radius: 100%;
The same brightly colored canvas, with every fourth pixel rounded at the bottom right corner

Voilà! With just three rules to set color and four to determine overall shape, we have a beautiful little piece of geometric artwork with nine variations that reveal themselves when you resize the canvas.

(My absolute favorite variation pops out when you shrink this final canvas to five columns 🥰)

Beauty in the grain

One thing I really love about these responsive pieces is the dramatic change in appearance that comes from playing with their dimensions. A single nudge can transform a series of braided, almost organic strands that crisscross the canvas into a rigid repeating pattern with little variation from row to row.

And even though we can fine-tune rules here and there, we’re really letting go of pixel precision and going with the grain of CSS. Moreover, I can’t say I would have thought to put those elements with those shapes in those places using those color distributions; these little canvases truly fall somewhere between handcrafted and generative art.

If you’re interested in playing with more of these, I’ve started to collect them over on my personal site. Here are two I’ve published so far:

(And if you start playing around with this kind of approach or have been already, please let me know! I’d absolutely love to see what kind of cool and creative things other people are making with responsive art.)


You know when you have fragments and snippets of ideas people have said or written just marinating in your head, then a few disparate thoughts bubble up together at just the right moment? 😙👌

Miriam Suzanne said this about CSS art on the terrific Word Wrap podcast:

People that are doing just absolutely weird shit with CSS is so cool. [...] I would love to see more CSS art that plays with resilience. How’s it going to fall apart?

Andy Bell echoed Miriam’s idea about resiliency when describing the responsive grid that makes the resizable canvas work:

Embracing the flexible nature of the web gives us resilient user interfaces. Instead of using prescribed, specific sizes, we give elements sensible boundaries and let them auto flow or resize where possible. A good way to think of this is that we provide browsers with some rules and regulations, then let them decide what’s best—using them as a guide.

Finally, Amanda Cifaldi built and maintains a beautiful art bot on Twitter called Tiny Spires, whose geometric designs float down my timeline several times a day.

Designing for a grid-based canvas whose dimensions are controlled by the viewer is, in retrospect, very much influenced by all of these.

Check This Out

Public libraries are a genuine treasure.

I'm a big fan of Libby—an app for Android, iOS, and the web—that lets you borrow e-books and audiobooks from your library.

I found that searching for a book or author in Libby based on a recommendation I ran across online was a little cumbersome for me:

  1. Copy (or try and fail to memorize!) the title or author
  2. Quit Safari and swipe around trying to remember which screen Libby lives on
  3. Launch Libby
  4. Hit the Search tab
  5. Paste my selection and kick off the search

So, like a good nerd, I made a bookmarklet called Check This Out that makes it a cinch to start a new book:

  1. Select a title or author on any web page
  2. Click or tap the Check This Out bookmarklet to launch Libby directly into a search
  3. Click or tap one more time to preview, reserve, or check out the book

Since the process of installing a bookmarklet on iOS isn't very straightforward I also made a site to help document the process, and decided to lean way into the vintage paperback book aesthetic:

Check This Out home page, styled to look like a worn vintage paperback novel Check This Out installation page, styled to look like a worn vintage paperback novel

I'll dive into a few details soon about building the site (the “animated stills” that demonstrate the feature and document the process of installing the bookmarklet were a blast to think through and build).

But for now—if you love to read, check out Check This Out.

An ode to invisible features

A lot of times we talk about “delightful” user experiences we tend to mean animations or other visual supplements to the interface. But there’s a whole world of nearly invisible features that are, in my opinion, just as enchanting.

Take GitLab for example, one of my very favorite tools, and this extremely vanilla, utilitarian view for kicking off a new pipeline (that is, building something from the current Git repo).

The GitLab pipeline view showing default branch selected

To run a pipeline for a non-default branch, you click the menu currently showing main, search for the branch you need, then click to select it. Simple, straightforward.

But I know from digging around in GitLab docs that ref is often used to refer to a given branch in many contexts. What would happen, you might ask yourself, if we tacked ?ref=<branch-name> onto the URL?

Exactly what you’d hope:

The same GitLab pipeline view showing the branch 'lorem/ipsum-dolor' selected

Maybe this tiny feature exists for reasons other than inquisitive guessers like me, but it opens up so much potential for automation outside the bounds of GitLab itself. Now I can create an Alfred workflow to run a new pipeline that accepts a branch name as its argument:

User productivity tool Alfred with command 'pipeline lorem/ipsum-dolor' entered

Giving me the tools to reduce friction and automate the things I do every single day? That is a textbook case of building in user delight.

Building a notification thingamajig with Eleventy data

This post assumes familiarity with Eleventy and Nunjucks.

I recently built and rolled out a small site that provides information about an upcoming event. The site needed to launch before every single detail could be locked down, so there are a few Check back soon! placeholders sprinkled throughout.

To help visitors spot when new information is available and guide them toward those updates, I built a little floating notification doodad that hangs out in the upper right corner of the page like this:

Page with white bell icon and a red notification badge, all enclosed by a black circle

The real site isn't publicly available, so I built a small demo site you can browse to see the whatsit in action.

Once a visitor browses the page that contains the latest update, the dingus disappears—until the next time a notification rolls out. Here's how I put the pieces together.

Eleventy Data

Behind the scenes, some simple Eleventy data defines notifications as an array of objects with just two properties:

        "id": "001-apple",
        "url": "/apple/"
        "id": "002-banana",
        "url": "/banana/"
        "id": "003-cranberry",
        "url": "/cranberry/"

This is a small, simple site and I want the notification thingamabob to appear on every page, so I’m able to define front-end logic directly in the page’s Nunjuck’s template without causing any undue clutter or concern that it’s being declared but never used.

As a result, I can use Nunjucks's built-in last filter to grab the most recent object out of the notifications array and then plop it directly into my script as an object literal using the dump filter:


    let notification = {
        lastDisplayed: null,
        latestNotification: {{ notifications | last | dump | safe }},

        // ...

When built, the resulting script block would look something like this:


    let notification = {

        lastDisplayed: null,
        latestNotification: {"id": "003-cranberry", "url": "/cranberry"},

        // ...

With notification data surfaced on the front-end, I can then build out some basic rules to be run on each page load that determine whether to show the whatchamacallit:

        // ...
        init() {
            // If the current URL matches latestNotification.url, the visitor is
            // browsing the page with the update. Write to
            // localStorage for future reference.
            let url = new URL( window.location.href );
            if( url.pathname === this.latestNotification.url )
                localStorage.setItem( "lastDisplayed", );
            this.lastDisplayed = localStorage.getItem( "lastDisplayed" );
            // If the doesn't match what we have in
            // localStorage, the user hasn't seen the page with the update yet.
            // Set data-notification="visible" on the body element for use by
            // our CSS.

            if( this.lastDisplayed !== )
                document.querySelector( "body" ).dataset.notification = "visible";

Finally, I can use a data-notification="visible" selector in my CSS to toggle the visibility on and off:

.notification {
    // ...
    display: none;

[data-notification="visible"] .notification {
    display: block;

A classic mashup of Eleventy data file and Nunjucks templating, a smattering of JavaScript, and a pinch of CSS are all it takes to implement a basic notification widget.

Accessibility and animation

Leaving the notification thingy to pop into view unceremoniously would have been fine, but it's usually at this point I ask myself "what would Dan* do?" This seemed like a good opportunity to add a little attention-grabbing pizzazz and try my hand at a bit of CSS animation.

I spent an inordinate amount of time tweaking the timing on all three elements that comprise the notification. Most of that journey is an unexciting montage of me flipping back and forth between 0.5s and 0.75s delays, with one notable exception: I made it a point to adopt the no-motion-first principle of animations I learned from Tatiana Mac.

By using an opt-in model for animation, the notification gizmo only animates into view if the user has has no preference for reduced motion and their browser supports the prefers-reduced-motion media query:

/* Animation */
@media (prefers-reduced-motion: no-preference) {
    .notification {
            opacity 0.75s 0.25s,
            transform 0.5s 0.5s;

    .notification:after {
        transition: all 0.875s 1.5s;

    .notification-icon {
            opacity 1s 0.5s,
            transform 1.25s 0.5s;

This ensures that the animation isn't shown to anyone who has set their system's reduced motion preference, or to any visitors whose operating systems or browsers don't provide that option.

*Dan Messing, my friend and coworker who is extremely good at adding delightful animations to interfaces he builds. (Keep an eye out for his handiwork in Playdate OS!)

Assumed Familiarity

In one of those exciting moments of chance—when you spot a pattern on the verge of emerging (or, at least, one that's new to you)—I recently came across a pair of banners by two different authors, each setting the stage for their respective posts.

First, a note from Stephanie Eckles leading into her article “Use Eleventy Templating To Include Static Code Demos”:

This post assumes a foundational familiarity with Eleventy. If you're new to 11ty - welcome! You may want to start here.

Second, this placard from Robin Sloan I unearthed while catching up on newsletters:

This message was emailed to the Media Lab committee. The assumed audience is subscribers who maintain their own websites or other small apps.

I take these advisories not as infernal warnings to abandon hope, but rather as road signs offered considerately to passersby, each of us at different points along our own journeys.

I hope to see a lot more of them around.

(See also Ethan Marcotte's newly launched courses on design systems, each with a clearly stated audience: for Everyone, for Developers, for Designers, and for Product Managers.)

A Few Tips for Capturing Screenshots

The documentation for a new starter kit I launched this week featured lots of screenshots stepping readers through configuring two different services. I wanted to make sure each image clearly conveyed a single idea without getting bogged down in extraneous details. These are a few tricks I picked up in the process.

Use your browser as a camera

I've built a strong muscle memory for quickly grabbing screenshots using the macOS keyboard shortcuts. I typically capture a region of the page and then head over to a simple image editor to tidy up my cropping, align like elements seen in other screenshots, add redaction, etc., until I have the image I'm looking for.

This isn't a bad approach—and sometimes it's the only way to achieve the result you're looking for—but the overhead of running your documentation workflow this way can really start to add up. Instead, we can use tools that are built right into our browsers!

Firefox, Chrome, and Edge include a variety of screenshot options:

  • Visible — What's currently visible in the viewport, without any browser chrome
  • Full page — Capture the entire page, including anything that's scrolled out of view
  • Elements — Hover and click to capture a specific element and its children
  • Selected region or Area — Click and drag to select an arbitrary region for capture

Firefox is my primary browser, so I mostly use the screenshot tools in their address bar. Chrome and Edge both offer a similar feature via the screenshot command in their DevTools command menus.

Compose in-camera

Rather than capturing the whole browser window and then switching to an image editor to crop it down to size, sometimes the quickest way to compose the right shot is as simple as resizing your browser and capturing the visible area:

Browser window resized to show only the desired section

This is similar to capturing a selected region using the system's screenshot tools, with the added benefit that you can reproduce the same frame size for other related screenshots (or retakes of the original if when necessary).

Deemphasize neighboring elements

In some cases, you might want to capture a bit more of the scene to help your readers orient themselves in the environment they'll be navigating while still drawing their attention to a specific element.

One approach is to fire up an image editor or screenshot tool and drop a nice big arrow or other visual indicator pointing to the element in question. This is a great option!

I tried a different idea with this project, and I think the results are pretty nice:

Netlify dashboard highlighting an action button, with all other text and elements faded out

This preserves the overall context that a reader will encounter, but focuses on an element that might otherwise get lost in the noise of a complex interface.

Here's what I did:

  1. Shrink the viewport to show only what's relevant (i.e., "zoom in" with our browser-camera)
  2. Select neighboring elements in the DOM inspector
  3. Add a new opacity property to each element's style rules
  4. Set a value that's appropriate — ex., 0.4 to keep it visible but bring things down to a whisper, or 0 to hide it altogether

This is a bit more involved than simply snapping a screenshot, but as we'll see in a bit it sets us up nicely for potential reshoots.

Capture just the element you need

Sometimes even the contents of the viewport are still too broad. Rather than shoot the visible area and crop it down, you can use your browser's tools to capture only the element you want to show:

Browser tools highlight a single element for capture

It works like a charm! With one click, we have a perfectly cropped screenshot with no stray background colors or clipped borders:

The single element as captured

Redact with real text

In some cases, you may be documenting an administrative interface like the Environment variables section of Netlify's dashboard. To prevent leaking sensitive information you could redact those details with a big rectangle, pixelated blurring, or some other obfuscation method using your favorite photo editing tool.

Another approach is to use Developer Tools to replace sensitive strings with text that looks similar enough not to be distracting. At a glance, it maintains the overall appearance that your readers will encounter:

Example showing admin dashboard with fake text that would otherwise reveal sensitive information

In this case, a snippet of Lorem ipsum set in Mocking Spongebob is close enough to the real API key's multi-case alphanumeric format that it doesn't draw attention.

This also lets you bring cohesiveness to otherwise disjointed screenshots. Maybe you switched sample projects halfway through, and now your ID numbers don't match. Don't reshoot the originals if you don't have to! Instead, bring the original project ID forward to your new shots by replacing that value any place it might appear.

Micro automation

Unfortunately, you can't always avoid retaking screenshots, sometimes multiple times. In those cases, writing and running short JavaScript snippets will help you quickly re-stage your environment.

For example, maybe each of the environment variable value elements above has a class env-var. All we'd need to do to swap in our replacement text (yet again!) is run the following snippet in the Developer Tools console:

    .innerText = "lOrEmIpSuMdOlOrSiTaMeTcOnSeCtEtUrAdIpIsCiNgElIt"

This lets us skip the DOM diving step altogether!

But what if, as is really the case with the elements above, there isn’t an easily accessible class or ID to use as your selector? All major browsers provide features for copying a selected element's CSS selector path:

  1. Open the DOM inspector
  2. Right-click the desired element
  3. Open the Copy menu and choose the appropriate selector menu item
Contextual menus for each of the three major browsers
Here's a great example of an image that needed some extra tinkering beyond what an in-browser screenshot could provide

Finally, add the otherwise fairly unwieldy selector to your opacity-adjusting snippet:

    > div:nth-child(2)
    > div:nth-child(1)
    > div:nth-child(3)
    > dl:nth-child(1)
    > dd:nth-child(2)
`).style.opacity = 0.4

and you're ready to re-stage that element next time.

Further Reading

If you're looking for other tips, Melissa McEwen has also been thinking about what makes for good blog post and documentation screenshots, and shared some great ideas.

Improving the Accessibility of Text-Only Tweets

Every few weeks, I see a tweet float down my timeline that goes something like this:

“This is how your cute tweet, overflowing with scripty text and the typewriter ‘font’ and/or sign bunny, sounds to users who rely on screen readers”

followed by a video of Siri wringing absolutely every syllable out of every Unicode character title:

“Vur-tih-cul line. Vur-tih-cul line. Back-slash. Un-dur-score. Un-dur-score. Slash. Bu-let…”

It’s genuinely bad! It's also frustrating, impenetrable, and—as these tweets mean to teach or remind us—completely inaccessible if you depend on a screen reader to navigate the internet.

Some admonitions go on, encouraging us to abstain from using text-based illustrations altogether. I think this is well-intentioned—abstinence probably is the best tool at our disposal for the moment—but I have a feeling it's probably not very effective in the long run.

I don't fault the request. People are just trying to ensure we make our gags and jokes accessible, or at least intelligible, to everyone. The trouble is, we humans love to express ourselves in unique and creative ways! (Most especially by copying what everyone else does ¯\_(ツ)_/¯) Asking people not to use memes, in any medium, feels like a losing battle.

What if—in addition to raising awareness about how Twitter friends currently experience our more visually whimsical tweets—we also encouraged Twitter-the-company to give us tools to make all of our tweets more accessible?

Text- and Unicode-based illustrations are, in essence, proto-images. Imagine if Twitter borrowed from the existing alt-text tooling they already provide. For example, if they detected characters in known ranges like 𝒸𝓊𝓉ℯ, 𝖌𝖔𝖙𝖍𝖎𝖈, box-drawing characters, etc., they could automatically display our old friend Add description on the tweet composition view, linking to the alt-text definition we know and love:

Mockup showing 'Add description' button on text-only tweet and alt text editor with description for that tweet

Consider this simple proof of concept:

  ┳┻|  psst! hey kid!
  ┳┻| _
  ┻┳| •.•)  BE SURE
  ┳┻|⊂ノ     TO DRINK
  ┻┳|     YOUR
  ┳┻|     OVALTINE

Wrapped with aria-label and role="img" attributes, all the characteristics of the original text are preserved—the ability to select, copy, and paste, for one—while assistive technology support is dramatically improved. (Go ahead, take it for a spin!)

No doubt it's a far more complicated affair on a platform like Twitter than what I've cobbled together here, but I'm confident engineers there could give everyday tweeters like you and me the tools and wherewithal to make our non-image illustrations more accessible.

A Few Things I Learned From “Beyoncé Knolls”

I’m fortunate to have a couple of weeks off of work to end this miserable year. The first mini project I finished during the break stemmed from a two-word pun that’s been rattling around my brain for over a year — Beyoncé Knolls. That is, “Beyoncé” as in “Beyoncé” and “knolls” as in “Always Be Knolling.” (Admittedly, this is a weak pun.)

Until very recently I had envisioned it as a Sketch project, something to be drawn by hand and exported as a static image to share on Twitter or wherever. I enjoy noodling around in Sketch, and making rectangles and shadows work overtime to give the illusion of CD jewel cases and LP covers was a nice diversion. But as the idea grew from “do the six studio releases and call it a day” to “illustrate Beyoncé’s entire discography,” constantly rearranging the grid layout to accommodate more and more releases got a little frustrating for a one-off Twitter gag.

I was on the verge of adding this to the dumb-visual-jokes-I-gave-up-on thread, when it hit me: drawing things semi-realistically with rectangles and shadows and then arranging them on a grid is precisely the domain of CSS. So I threw in the towel on throwing in the towel and got to work.

The final result looks like this:

Grid of Beyoncé album covers

It might also look like this, depending on the size of your browser and the scale at which it is zoomed:

Grid of Beyoncé album covers arranged differently to accommodate a smaller browser

Which brings us to my first takeaway…

Grid is incredibly powerful

It’s old news to front-end developers by now, but my goodness is Grid an incredible tool.

The complexity and sheer number of media queries it would take to pull off the same layout in such a fluid and sturdy way using traditional responsive design techniques is mind-boggling.

On the contrary, the magic that makes the Beyoncé Knolls layout responsive without resorting to a hundred fiddly, brittle breakpoints is this unassuming little powerhouse:

grid-template-columns: repeat(
    minmax( var( --grid-min ), 1fr )

Wild, right? repeat, auto-fill, and minmax are a hell of a combo.

(Confession: I do use two media queries to adjust the --grid-min custom property at very rough breakpoints to ensure that at least two columns are visible at most viewport widths. This is less to do with Grid and more to do with the fact that a single column of squares and rectangles can hardly be considered knolling…)

Using auto-fill with minmax for the first time shone a bright light on something I’ve known academically but finally learned first-hand.

A case for container queries

To help maintain the semi-realistic appearance of each object, I tried to ensure that important visual styles remained proportional to their parent elements.

Real DVD cases, for example, have a fairly consistent radius to their rounded corners and a pretty standard inset between the edge of the case and the cover insert. Straying too far in either direction—a cover that’s too close to the edge or too far away—ruins the illusion.

I got lucky with the corners. border-radius accepts <percentage> units, and percentage values are relative to their parent elements:

.item-dvd-cover {
    --aspect-ratio: calc( 7.50 / 5.30 );
    border-radius: 2.5%;

The inset—the black border separating the top, right, and bottom edges of the cover art from the edge of the case—is drawn using box-shadow. Unfortunately, box-shadow only supports <length> units like rem or vw, none of which are calculated relative to their parent elements.

This is the perfect job for a media query, right? Adjust the shadow offset at a few key breakpoints, bing-bang-boom. Well, not really. Consider two instances of the same DVD element, with the left viewport just 1 pixel wider than the right:

Two browsers showing the same DVD element at very different sizes

Because the width of each grid track can change dramatically with the slightest change in browser width, trying to ensure a proportional box-shadow offset using the viewport size alone is completely impractical.

I ended up using clamp with vw to help keep the inset from being too big or too small for the parent element:

.item-dvd-cover:before {
    --cover-inset: 0.1625rem;
    --cover-inset: clamp( 0.125rem, 0.15vw, 0.2rem );

        -0.20rem 0.20rem 0 0.20rem rgb( 20, 20, 20 ) inset,
        -0.20rem -0.20rem 0 0.20rem rgb( 20, 20, 20 ) inset,
        0 0.375rem 0.25rem -0.25rem rgba( 255, 255, 255, 0.33 ) inset,
        0.0625rem -0.0625rem 0.125rem rgba( 0, 0, 0, 0.5 ) inset

and it’s good enough for this project, but it still feels like a ham-fisted, imprecise approach for what I wanted to accomplish.

My takeaway after wrapping up Beyoncé Knolls: media queries are great for defining rules that specifically relate to the height or width of the viewport, but those dimensions can be a poor or wholly inaccurate proxy in other contexts. Or, as Ethan Marcotte said more eloquently:

We’re making design decisions at smaller and smaller levels, but our code asks us to bind those decisions to a larger, often-irrelevant abstraction of a “page.”

Container queries, it seems, would be a welcome tool to tackle problems of this genre. They wouldn’t outright solve the issue of building box shadows with offsets proportional to their parent elements, of course, but they would allow for more context-appropriate breakpoints than media queries could ever support.

CSS is Awesome

It really is ☕️.️ It’s a rich and complex language, and I’m in deep, genuine awe of people who have made careers out of learning to use it well.

Optimistic Routing with Netlify Rewrites

Phil Hawksworth wrote a great article on building “static-first” sites that mix pre-rendered pages with server-rendered content, all using user-submitted data.

I’m using a similar approach for a side project and ran into a confusing little hiccup when deploying to Netlify. I figured I’d document the behavior I encountered along with a workaround.

💡 tl;dr — If you’re using Netlify rewrite rules to route requests to a serverless function, any query string parameters defined in your to value are lost. You may be able to recover the information you need by using the originally requested URL path instead.

Static-first sites

You really should read Phil’s article (and check out the accompanying site Virtual Popsicle), but the basic idea behind a static-first site is this:

  1. A user submits a new entry for the site — ex., they create a new popsicle
  2. The submission is POST-ed to a serverless function that stores the new data in a remote database and triggers a Netlify deployment using a build hook. In the case of Virtual Popsicle, the function also gives the user a friendly URL for their newly created popsicle page to send to the intended recipient — ex.,
  3. During deployment, Eleventy—or whichever static generator is being used to build the site—fetches all user-supplied entries from the database and builds them into individual static pages.

This is a great use of Eleventy’s JavaScript data files and pagination features, bringing the speed and reliability of statically generated pages to sites that largely feature user-submitted content.

Mind the gap

Netlify deployments move pretty darn quickly but they aren’t instantaneous. This leaves us in a bit of a pickle: there’s a window of time between the moment our user submits a new entry and when its corresponding page is finally built, deployed, and ready to be browsed. During this gap, attempting to visit /popsicle/trrQq6Oag will result in a 404 error.

This might be acceptable in cases where site generation runs in the 10- to 20-second range, but as the number of user submissions grows (or if we switch from on-demand to scheduled deployments) it becomes less and less tenable.

Phil offers a rather elegant solution for handling requests during this period of time:

Until the site generation is complete, any new lollipops [aka “popsicles” 😉] will not be available as static pages. Unsuccessful requests for lollipop pages fall back to a page which dynamically generates the lollipop page by querying the database API on the fly.

This works by using a Netlify redirect rule that serves as a catch-all for any requests for popsicle pages that haven’t been built, redirecting them to another serverless function showLolly.js:

  from = "/popsicle/*"
  to = "/.netlify/functions/showLolly?id=:splat&lang=us"
  status = 302

The function uses the id query string parameter defined by the redirect rule to fetch the appropriate entry from the database, build HTML that’s identical to our static pre-rendered pages, and return that to the visitor’s browser. Eventually, the site will rebuild and deploy, and the popsicle page will be served statically.


Redirect vs. rewrite

Redirecting URL misses to a serverless function that temporarily supplements static, pre-generated pages is a really clever approach. There’s one tiny thing I wanted to change for the implementation in my project.

Since Virtual Popsicle uses a 302 redirect for its fallback popsicle rule, anyone who opens a not-yet-static link will find that their browsers are redirected to the less friendly serverless function URL path /.netlify/functions/showLolly?id=trrQq6Oag:

Browser showing full serverless function URL instead of the friendly version

This is expected based on the configuration and it’s hardly what I’d call a deal-breaker.

Still, I want fallback handling to be invisible to visitors of my project if at all possible. That is, visiting the soon-to-be-static URL will ideally display the dynamic server-rendered page without exposing the serverless function URL.

Netlify supports this precise behavior with “rewrites,” directives that are nearly identical to redirect rules but which use a 200 status instead:

When you assign an HTTP status code of 200 to a redirect rule, it becomes a rewrite. This means that the URL in the visitor’s address bar remains the same, while Netlify’s servers fetch the new location behind the scenes.

It might look something like this for Virtual Popsicle:

  from = "/popsicle/*"
  to = "/.netlify/functions/showLolly?id=:splat&lang=us"
  status = 200

I made the switch from 302 to 200 in my project‘s redirect rules, deployed to production, and then things went haywire...

The case of the missing query string parameters

Let’s rewind a bit and take a quick peek at my original redirect rule and serverless function:

  from = "/fruit/:slug"
  to = "/.netlify/functions/showFruit?slug=:slug"
  status = 302
let handler = async (event) =>
	let slug = event.queryStringParameters.slug;

	if( slug )
		// Do the page rendering...
		response.statusCode = 400;
		response.body = "Missing required parameter 'slug'";

	return response.body;

Like Phil’s popsicle site, the redirect rule creates a query string parameter slug using the :slug component of the original URL path as its value. This is passed off to the showFruit.js serverless function which checks that the query string parameter has been set; if not, it returns a brief error message explaining that the request wasn’t properly formed.

This all worked great. When I switched to a rewrite rule, however:

  from = "/fruit/:slug"
  to = "/.netlify/functions/showFruit?slug=:slug"
  status = 200

and deployed the change to Netlify, my fallback started giving me an unexpected error message:

Missing required parameter 'slug'

even in cases where redirections had been working previously. More bizarrely, I couldn’t reproduce the issue using Netlify Dev, a command-line tool for emulating Netlify’s platform when working locally.

I added some real galaxy-brain debug logging to the function to take a closer look at what might be happening to the slug parameter:


Lo and behold, the function log in Netlify’s dashboard revealed that the query string parameters object was flat out empty:

1:41:59 PM: 2020-12-29T20:41:59.945Z	INFO	{}

But why? It turns out this is a known issue with Netlify’s rewrites behaving differently from redirects.

A known issue and a workaround

I did some DuckDuckGo-ing and found a post from last September in Netlify’s community forums describing exactly the behavior I was seeing: query string parameters defined in the to key of a 200 redirect rule are stripped somewhere along the journey from the original request to our serverless function.

The original poster followed up with a workaround that uses the query string parameter if present, and otherwise falls back to parsing the path of the requested URL:

let slug = event.queryStringParameters.slug
	? event.queryStringParameters.slug
	: event.path.split( "/" )[2];

It works beautifully for both redirects and rewrites, whether the script is running locally or in production.

⚠️ — It’s worth noting that this only helps recover values the rule plucked from our original path, like :slug in /fruit/:slug. If you were to append new information that does not exist in the path — ex., lang=us — using event.path would not help.

Development vs. production

There’s an open issue for the discrepancy in behavior between Netlify Dev and production in this scenario, but it sounds like the change from Netlify will be to make Dev work more like production than vice versa.

The good news is with the workaround in place, serverless functions can handle either behavior seamlessly.

The Seams

My pal Dakota was a glass blower in a past life. I’ve seen a few pieces he made and they’re stunning; elegant but not delicate, like turbulence frozen in time. He really knew his stuff.

We were lollygagging with some other friends a while back and popped into a little curio shop. I caught him eying an ornate piece of glasswork, and asked what he thought. He pointed out a few lines and ridges and, without hesitation, concluded it was not an artisanal creation, as I assumed, but an industrial one.

The marks were tells. They revealed an invisible story of how the piece came to be, one that Dakota could read and I (the sucker, the rube) could not. It was made from a mold, just like its untold identical siblings.

I think of mastering an art (or trade or tool or technology) to that degree—assessing a strange piece of work and seeing not just the thing itself but its constituent parts and how they came together—as learning to see the seams. (Same, too, for the inverse: sizing up a task and, even in the early thrill and chaos of raw material, seeing a way forward with clarity.)

It's something I'm thinking about as I struggle to transform a new design for this site from sketch to reality. I can't immediately see the underlying HTML and CSS required; how grid and flex will work together with media queries and minmax to bring the complexities of the layout to life.

I don't yet know how to spot the seams.

But this stage I'm in right now—equal parts excitement and frustration, progress and setback—this is the good stuff. These are the heady days of learning.

State of the Art

As the U.S. barrels toward Election Day—one that feels, in a very real way, like it could be our last—I’ve tried to fill my fractured thoughts with pursuits that are alternately mindless or educational. Most recently, I've been building my own little reactive state manager using vanilla JavaScript to better understand how they work—and stumbled across a bit of a historical wormhole in the process.

Another State Manager?

The field of state management tools is crowded, to say the least, with well-known, well-tested libraries and frameworks written by scores of developers who actually know what they’re doing. So why build my own?

I’ve used both Vue and React in the past, but never stopped to consider what it would take to implement reactive state-based rendering. It has always felt a little magical, to be honest: update the value of a single property and watch changes cascade across an interface automatically (and predictably, which I love).

When the complexity of a small side project outgrew one-off functions and application state defined in DOM element attributes and values, it felt like a good opportunity to pull over and finally try building a manager myself. Since my goal was to better understand the mechanics of implementing reactive state rendering, rather than to enter my rookie contender into the public fray, I wasn't shy about borrowing from the vernacular of Vue and friends.

This is where I landed:

let game = new State({
    data: {
        letters: ["a","d","i","n","o","p","r"],
        requiredLetter: "a",
        solutions: {
            draft: [],
            accepted: []

    computed: {
            // 4-letter words are worth 1 point; (n > 4)-letter words are worth n points
            return (total, word) =>
                return total + word.length < 5 ? 1 : word.length;
            }, 0 );

    render() {
        // ...

If you're not familiar with this pattern, the data option object passed to the State class constructor defines static properties that can be read or written by the containing application. These are made available via the State instance's data object:

Animated illustration showing the result of setting a static data property

Similarly, the computed constructor option is an object that defines dynamic properties whose values are crunched and returned by functions. They are accessed via getter and setter shims on, right alongside their static cousins:

Animated illustration showing a computed property automatically updating as a result lf setting a static property

Finally, whenever the value of a property on is set the manager invokes the user-provided render option, a callback responsible for ensuring that the relevant UI bits accurately reflect values defined in state. When invoked, the callback has its this context set to a read-only clone of, giving the renderer direct access to the current snapshot of state at that moment in time.

None of this is groundbreaking or innovative, of course, but dang does it feel great to pop into developer tools, update a single value, and see the results spring to life:

Animated illustration showing setting a static data property resulting in UI updating automatically

just like in the big leagues.

Illuminating the Past

Around the time I rolled up my sleeves on this little research project, I was just starting to work my way through Articles of Interest, a podcast mini-series about clothes, fashion, and other things we wear.

Episode 1 introduces us to the Jacquard machine, a loom peripheral of sorts invented in the early 1800s that brought automation to the art of weaving and textile manufacturing. Here's a peek at one in action:

While my brain was background-processing some ideas about state management improvements I was working through at the time, a single line in the episode caught my full attention:

One card represented just one pass of thread.

At any given moment in a state-based renderer—whether that’s Vue, React, or even my little research project—the equivalent is a collection of values describing how to produce a precise, predetermined pattern.

If we dial down the resolution on our state data (and suspend quite a bit of disbelief for the sake of artistic interpretation):

Animated illustration showing a series of dots drawn to resemble the layout of the game state JavaScript object

it's not too much of a stretch to see something familiar looking back at us across the great span of technology:

Animated illustration suggesting a visual parallel between the game state JavaScript object and Jacquard loom cards

Set. Render. Set. Render. And on and on.

Contrived visual skullduggery aside, it’s not wholly unlike advancing the next card into a Jacquard machine, then sending the shuttle on a pass through the loom to bring our pattern to life.

As Christine Jeryan and Avery Trufelman both reminded us, we can draw a rather direct line from computers as we think of them now, through punch cards, all the way to their textile ancestors. But I think it’s also comforting to know we don’t have to look far to find living fossils in the contemporary development tools and design patterns of today.

Called From Wild and Far Away

This single from Future Islands snuck up on me in these early, uneasy days of fall 2020. It reminded me of one of my favorite little moments:

You know when you’re boppin’ through life just minding your own business, and a song pulls up alongside you and matches your speed? Can’t will it to happen, or invite it; can’t go looking for it; just happens. And eventually you look up and over, catch its gaze, and—this is my favorite part—suddenly you can’t remember not knowing the damn thing. It’s part of you now, always has been.

I love that.


My Twitter pal Rob Fahrni shipped an iOS app this week, one that’s near and dear to my heart. It’s called Stream and it’s a feed reader for iPhones.

Here’s the pitch:

[All] your feeds appear as a unified timeline. With its simple user interface, ability to import existing lists, and support for open reading standards, Stream is a great choice if you’re looking for a different experience.

I think he nailed it:

Stream's timeline and article view
Stream’s timeline and article view

Stream is free on the iOS App Store. (And don’t miss that killer icon — with six variations!)

Congratulations, Rob! You did it 🎉

Friction & Non-Fiction

One of my all-time favorite radio hosts, Jacob Goldstein of Planet Money, has a new book out. It’s called Money: The True Story of a Made-Up Thing and in it he tackles the lofty goal of showing us that money is “a useful fiction” we tell ourselves, one that has shaped—and is shaped by—the societies that invented it.

Any longtime Planet Money listener will tell you this is an obvious purchase: Jacob, the funny, insightful, and casually informative co-host of the show about money wrote a book about money? No-brainer. It was equally obvious to me that I’d opt for the audio version. After years of listening to the show, his voice is instantly familiar and reassuring, two simple things I seek out in These Tumultuous Times®.

But there’s a wrinkle...

Big Audio

Cory Doctorow recently reminded us that a single behemoth effectively has a stranglehold on the audiobook corner of the publishing world. I’ll spare you any pretense it’s not the exact company you’re thinking of, the one with its mitts around so many other industries today. It’s Amazon, of course, via their Audible subsidiary.

We can’t, any of us, completely detach ourselves from Amazon, but with a renewed sense of determination not to support them directly and with, uh, Money on the line, I decided to explore other options.

As an iOS user, the next obvious choice was Apple Books. I'm no Apple lifer, but even as a relative newcomer to the indie Mac and iOS software development world, I've watched them grow from a seemingly enthusiastic partner into a hostile landlord. I can't imagine they're any better to news or book publishers, so it doesn't feel right to tap Buy just to save myself some time and a couple of bucks.

Fortunately, there is such a thing a small (indie?) audiobook store. I've noodled around with a few of them in the past, but always get frustrated by small differences between their custom apps and the spoken-word audio player that feels like an old glove: my podcast app of choice, Castro.

I was paging through the various indie audiobook stores again when it hit me like a name-brand vegetable juice conk to the forehead—Castro! Of course!


A few years ago, the Supertop fellows rolled out a feature to Castro called Sideloading. It's a really handy feature I've used a number of times to drop MP3s and other non-podcast audio right alongside episodes of Planet Money and all the other shows I subscribe to. Surely it would work just as well for an audiobook.

I started digging around for a store that sells DRM-free copies of their books and, importantly, doesn't tie them up exclusively in their proprietary player. There may be others, but Downpour caught my eye and seemed to fit the bill. After several taps and some finger-crossing, I was listening to Money in Castro.

Because the audio files were zipped into a single bundle, it wasn't quite as straightforward as using the share sheet. It went something like this:

  1. Buy Money from Downpour
  2. Open My Library and tap the audiobook cover
  3. Because they're trying to be helpful, I suspect, Downpour hides the download option on mobile devices. To bring it back, reload the page using Safari's toolbar menu item Request Desktop Website
  4. Tap the blue Download button and then the orange Download button
  5. When the download completes, launch the Files app
  6. Locate and tap the zipped audiobook file to uncompress it
  7. When that finishes, tap the resulting folder to reveal the individual files
  8. [Wipe brow, take a sip of water]
  9. Select all files and copy or move them to iCloud Drive → Castro → Sideloads
  10. Launch Castro, and—hey presto!—all 17 tracks are waiting for you in the Sideloads section Library tab

It is, without a doubt, not nearly as effortless or inexpensive as swallowing my pride yet again and getting another book from Audible, but boy does it feel good.

Eleventy Clock

I'm thrilled to announce Eleventy Clock, a brand new 720-page website that provides all the blood-pumping, nail-biting, chronometric exhilaration you'd expect from reading the current time. This isn't a single-page site that updates itself periodically—no, no!—but seven hundred twenty individual pages, painstakingly crafted to bring you every minute of every hour with utmost precision and accuracy.

Rendering of bright red flip clock on a white surface in front of a teal wall

It works like this. When you visit the landing page, a small blob of JavaScript crunches the current hour and minute according to the clock on your computer (phone, tablet, what have you), then redirects your browser to the static, nearly logicless page representing that time.

For example, if it were currently 11:11, you'd be redirected to and be greeted by a cheery, bright red flip clock sporting a pair of snake eyes.

But what good is a clock that's frozen in time? Not much. So this page has its own pinch of JavaScript that redirects back to the landing page when 11:12 is nigh. The landing page then redirects to /11:12, which eventually redirects back to the landing page... And on and on this goes, forever (or until you move on to something more worthy of your attention).

Now, asking a web browser to display the current time is fairly banal. (We can do it right here, in fact: . See? Nothing special.) So why go to all the trouble to build a site that tells time in such a backwards, uneconomical fashion?

The answer, as with all silly projects, is: to learn something new 📚


In case it's not obvious, allow me to reveal the big secret: I didn't create all 720 pages by hand. That would be bananas. Instead, I built Eleventy Clock while exploring pagination, a feature of its eponymous static-site generator that I hadn't used or even really understood until I saw this tweet from Vince Falconi:

It took me a good while to learn that 11ty’s pagination is not the pagination I thought it was.

I expected it to make next/previous and enumerated links, but no, it takes a collection and applies the template to each item. Powerful if your building from a non-file data source.

@vincefalconi • August 18, 2020

I had skimmed the pagination documentation before, but like Vince I assumed it was meant for building navigation from collections of pages and other data. You can use pagination to build Next and Previous links, of course. But after taking a closer look, I think the real power of Eleventy pagination is in its ability to generate static pages outside the traditional 1:1 relationship between templates and their output.

This got me thinking. What kind of data source could you use that pushes beyond what might typically be feasible or desirable to create by hand? Truth be told, my first instinct was 🌈 every hex color but building an array with 16,581,375 elements proved a formidable match for poor old Node, which fell over.

So I changed gears to a more manageable data set: 720 elements representing times from 1:00 to 12:59 in one-minute increments:

let times = [];

for( let h = 1; h <= 12; h++ )
	for( let m = 0; m <= 59; m++ )
		let time = {
			h: h,
			m: m.toString().padStart( 2, "0" ),

		times.push( time );

module.exports = times;
Adapted from src/site/_data/times.js

By adding pagination to the frontmatter of a single template:

    data: times
    alias: time
    size: 1
permalink: "/{{ time.h }}:{{ time.m }}/index.html"
Frontmatter adapted from src/site/pages/

and specifying the data source as data: times, we can tell Eleventy to use the array that results from the global data file _data/times.js. Though pagination supports multiple data items per "chunk", since I just want a single hh:mm permutation per page I specify a size of 1.

(By default, we would reference the current pagination item in our template using pagination.items[0], but Eleventy supports aliasing as a convenience. Using alias: time, pagination.items[0] becomes just time. Nice.)

Finally, permalink brings everything together. Using the h and m properties of our "current pagination item" time alias, Eleventy builds a single page for each of the 720 array items exported by times.js.

Anticlimactically, that's it*! The true story of how a big-little site (whose only function is already served by every watch, smart phone, fax, pager, and stove in the world) came to be.

*Well, that's not really it. A few days of wrangling and wrestling CSS gradients, box-shadows, border-radii, and other tricks nicked from Lynn Fisher's awe-inspiring A Single Div followed. But aside from that...


I've been diving in and out of INI files at work a lot lately. One of our projects uses them to manage state in important ways, and some days it feels like all I've done is set or delete the same handful of values, check the results, then do it all over again. (Kind of like that schtick at the optometrist. "One. Two. Back to one. Here comes two again...")

If you're not familiar, INI files contain key=value configuration definitions like this:

street=SW 11th Avenue

Sometimes they get real fancy and use sections:


Unlike JSON and YAML files, which explode any time you have the audacity to overlook a trailing comma or rudely insert tabs instead of spaces, INI files aren't a hassle to edit by hand. Still, I found myself wishing for the simplicity and ease of that old Mac command-line standby, defaults.

So, I wrote innie, a tool for reading, writing, and deleting INI file entries. I even made a little icon, which is pretty ridiculous for something that runs in a terminal:

Square with rounded corners. Two white square brackets are oriented to form a capital letter I, with pink-to-purple gradient behind.

Borrowing a page from defaults, it offers three subcommands — read, write, and delete. Let's take it for a quick spin:

$ innie read ./data.ini zip
$ innie write ./data.ini zip 97206
$ innie read ./data.ini zip
$ innie delete ./data.ini zip
$ innie read ./data.ini zip
innie: No such key 'zip'

Impressive, huh? 😉

"What about those swanky sections we saw earlier?" you ask, observant as ever. innie supports dot-notation for referencing nested keys:

$ innie write ./data.ini weather.aqi 58

Just incredible.

If you'd like to take a closer look, innie is available on NPM. You can find instructions for getting set up there or in the Git repo README.

Time Machine

For the last seven-odd years I’ve kept a running log of the songs I’m listening to at any given time.

It’s a simple routine I started with the late-great Rdio: create a new playlist at the beginning of each month—“May 2015”—and drag songs in as I recognize they’re on regular repeat.

It’s imperfect and lossy; undoubtedly, many songs have fallen through the cracks over the years. Still, I find the ritual of doing it by hand to be soothing, and it’s such a gift to travel through time for just a few minutes.

I've been seeking out comfort lately, as I'm sure a lot of people can relate, and finding it in a lot of music that makes me think of the past.

— Casey Kolderup, Amplifier

Especially now.

Perf’s Up

Last month, Zach Leatherman retooled the list of sites built with his static-site generator Eleventy, transforming what was originally a flat list into a leaderboard of sorts:

Sites with Lighthouse scores greater than or equal to 90 are ordered by performance. The remainder are ordered randomly. Performance rankings are updated approximately once per week on Sunday.

I have a single-serving site on the list, Eats & Drinks. It's nothing special, just a list of places in Portland my wife and I enjoyed when we lived there. (We moved to Denver last fall, so the site is on permanent hiatus.)

As Zach noted shortly after launching:

I’ve already received multiple reports of people updating their sites to be even faster to try and break into the Top Eleven results.

I'm not a competitive person and Eats & Drinks is no longer in active development, but I started to feel bad watching my little site drop in the rankings week after week, so I dusted things off and got to work. Here's what I've done so far to improve on the site's original SpeedIndex median score of about 1887.

wave off Boomerang Beach, Australia
Photo by Holger Link

First Pass

I know very little about web performance strategies and have absolutely zero experience improving an actual site's actual performance, but I do listen to ShopTalk Show! I knew my first order of business should be to move some things off the critical rendering path.

Because the site is only one page, I originally defined its CSS internally using a single <style> tag up in the <head> block. I also bring in two fonts in a few different weights using a third-party stylesheet from Typekit:

	<link rel="stylesheet" href="">

	{% set css %}
		{% include "css/index.css" %}
		{% include "css/header.css" %}
		{% include "css/footer.css" %}
		{% include "css/location.css" %}
	{% endset %}
	<style>{{ css | safe }}</style>

I figured I could safely defer <footer> styling as well as font loading by moving them to the very end of <body>:

	<link rel="stylesheet" href="">

	{% set css %}
		{% include "css/footer.css" %}
	{% endset %}
	<style>{{ css | safe }}</style>

There are tools to fine-tune and automatically identify which styles fall on the critical path, but this seemed like a good place to start. Did it help the following week's ranking?

Performance Rank #74 ↑ 46

Sure did! Jumping solidly into the top 100 isn't too shabby for a few lines of work.

Digging In

It turns out that development based on performance ranking is a virtuous cycle, and it's kind of fun to boot. But my method of working (very) asynchronously—make a change, wait until Sunday for the results—felt a little ridiculous, so I started running my local development copy of Eats & Drinks through the Lighthouse CLI to find more areas for improvement.

Putting the "D.I.Y." in font-display

Lighthouse flagged an issue with text rendering:

Ensure text remains visible during webfont load

Unfortunately, Typekit's @font-face definition explicitly specifies font-display: auto and they don't provide any support for the new swap value that instructs supporting browsers to display text in a fallback font until the specified font is ready. (This results in brief period during rendering when no text is visible at all, which makes for a pretty janky experience.)

This felt like an insurmountable hurdle until I realized I could bypass Typekit's entrypoint CSS and host my own version instead, with one important modification:

font-display: swap;

My original idea was to create a drop-in replacement for the Typekit-hosted CSS, swapping in a self-hosted external CSS file for theirs:

<link rel="stylesheet" href="/fonts.css">

But I had a hunch there was still a bit of room for improvement: any external CSS file, whether it's mine or Typekit's, still requires an additional network request. Since I'm now self-hosting the @font-face CSS, I can load it however I like:

	{% set css %}
		{% include "css/fonts.css" %}
		{% include "css/footer.css" %}
	{% endset %}
	<style>{{ css | safe }}</style>

Moving font definitions to the internal CSS alongside <footer> styling at build time comes in at a median SpeedIndex score of... 843 🔥 Hot damn!

I don't know for sure how these changes will pan out in this week's rankings, especially since plenty of other folks are busy tuning their sites as well, but I'm optimistic I'll see at least another modest jump 🤞 And even if not, I learned a little bit about how to make sites more performant.

Time Travel with Netlify Deploys

Unfortunately, I didn't grab Lighthouse scores for Eats & Drinks in its original, pre-improvement state. Lucklily, Eats & Drinks is hosted on Netlify! Thanks to their immutable deployments feature, it's easy to quickly run Lighthouse against any point in the site's history.

Just like we can pass in a production URL:

$ lighthouse \
	--chrome-flags="--headless" \
	--only-categories=performance \
	--quiet \
	--output=json | jq '.audits["speed-index"].numericValue'

we can also test against the deploy preview URL for any commit we want to investigate:

$ lighthouse \
	--chrome-flags="--headless" \
	--only-categories=performance \
	--quiet \
	--output=json | jq '.audits["speed-index"].numericValue'

😗🤚 P.S. If you work with JSON on the command line even a little, do yourself a favor and add jq to your toolset.

GitLab Quick Actions

GitLab has become an invaluable part of how we do development and testing at Panic, spanning products, platforms, and disciplines. We use it to host our projects, track issues, organize milestones, and manage merge requests ("pull requests" in the GitHub nomenclature).

Issues and merge requests in particular are constantly in flux: assignment changes, labels come and go, milestones get assigned and reassigned. GitLab's interface is great—things are where you'd expect them—but I'm always looking for ways to reduce friction and automate things I do all day long.

GitLab Quick Actions are a huge gift to folks like me who feel most productive using a keyboard. Any comment field doubles as a kind of context-specific command line:

Performing three quick actions on a merge request in GitLab

Throw in a quick Command-Return to submit & execute

It's a small trick, but quickly performing a wide array of actions all without leaving the keyboard is a real game changer.


The underlying functionality deployed quietly a couple of weeks ago, but a new featurette I'm calling All-Stars will make its little debut on the home page today, just below this post.

Here's how it came together.

silhouette of mountains in front of stars
Photo by Denis Degioanni


If my math is correct, I've been reading for damn near half my life.

When the idea to build Multiline Comment started popping around my daydreams, I knew I wanted to borrow a small Kottke-ism straightaway: a Quick Links-like interstitial made up of articles and other links I've been reading around the web.

(I don't flatter myself to think the two-penny opera I'm conducting here is comparable to what Jason has built over the last twenty-odd years. My insight doesn't run deep enough to pull off a full-blown link blog, for one. Still, my internal recipe for what constitutes a blog is so deeply seasoned by two decades of following his work that I'm bound to cop a move or two, consciously or otherwise.)

Design & Planning

By virtue of building this site with Eleventy, posts like this one are created as individual Markdown files and then extruded through Nunjucks templates at build time into blog-shaped HTML. It's a regular Mop Top Hair Shop up in here.

For one hot, stupid minute, I considered using a global JSON data file for logging and publishing links. (It passed quickly.) Creating and editing text files is perfect for writing whole blog posts, but it's too much overhead for easily sharing a URL here and there, and anyway editing JSON by hand makes me grumpy.

I wanted the process to be easy enough for it to become sticky; realistically, if it was too cumbersome, or required more than a tap or click or two, I'd never get into a good rhythm. Fortunately, I didn't have to look far for a proper solution.

Made of Star-Stuff

I'll tell anyone who listens how much I love Feedbin, my RSS service of choice. I spend a good portion of my browsing hours checking in there to catch up on blogs, email newsletters (yup!), and other feed-backed goodness.

Like all good websites, Feedbin uses stars for faving. Read something you like? Hit the star and fave it. Easy peasy. They also expose an API, which includes a query for faved items. If you squint just right, you can see the glimmering of a little link roll

The basic functionality, then, should be this: faving something in Feedbin will make it appear in All-Stars (eventually). Let's dig in!

Fetching Faves

One thing I love about Eleventy is its flexibility. I've tried a number of static-site generators in the past, only to get frustrated by how their visions for a site structure or layout don't match mine. Eleventy is different: everything feels designed to give people a hand building whatever comes to mind. It's a tool not a solution, in the best possible sense.

A particularly nice and powerful feature of Eleventy is its support for JavaScript data files, useful for generating—or fetching!—dynamic content at build time. A script can return static data:

module.exports = [

a function:

module.exports = function() {
  return [

or even an asynchronous function:

module.exports = function() {
  return new Promise((resolve, reject) => {

This is perfect for grabbing data from a live API, munging the results, and handing everything off to the template system for rendering, which is exactly how the back half of All-Stars is implemented.

The API-calling logic lives in one data file and doesn't do anything too fancy:

  1. Ask Feedbin for all my starred entries
  2. Sort them by id, largest to smallest (a so-so proxy for "most recently faved")
  3. Grab the six most recent entries (a nice, round number for layout: 1×6, 2×3, or 3×2)
  4. Clean up the hostname for display purposes (i.e., strip any leading www.)
  5. Give everything back to Eleventy

That's it!

You'll notice my Feedbin credentials aren't stored in the script or anywhere in the site repository. (I love you but we can't share a login; this isn't Netflix.) Instead, the script looks for two environment variables, FEEDBIN_EMAIL and FEEDBIN_PASSWORD. During development, they're set in a .env file at the root of the project folder. In production, they're defined in Netlify's friendly deployment dashboard.

Fetching Fakes

Adding an external source to my build process introduces a new wrinkle to doing local development.

I use Eleventy's live-reloading serve function while tinkering on the project, which means the site is built each time I save my work. This is handy quality-of-life tool, but I'm not always online while I'm working. Even if I were, I don't really want to hit the Feedbin API every single time I make a small change.

I added a quick Are we in production, or explicitly testing the Feedbin API? check at the top of the data function:

const {starredEntries} = require( "./dev/allStars-entries" );

// ...

if( process.env.NODE_ENV !== "production" && !process.env.FEEDBIN_LIVE )
    console.warn( "Using development data for All-Stars" );
    return Promise.resolve( starredEntries );

If not, the function immediately resolves with a set of fake data objects. Development build times are nice and speedy without a roundtrip to the Feedbin endpoint, and I don't have to worry about whether API requests will resolve.

(P.S. Ask me how I'm using Nova's awesome Build & Run Tasks feature to make starting the development server, testing live Feedbin data, and other development details a breeze 💫)

Rendering Faves

I love a good, tidy organization system, and breaking discrete components out into standalone templates really pushes my buttons. The All-Stars component is short and sweet.

One thing that tripped me up for a bit was how to place the component where I wanted it. I've always liked the interstitial layout on the home page—the most recent post comes first, followed by Quick Links, followed by everything else—and I had hoped to emulate that. I mistakenly looked through the Eleventy documentation at first for a tool to tackle inserting an unrelated template in the middle of a data collection, but this seems to be the purview of templating languages instead.

Nunjucks offers a loop.index property, giving the current iteration of a given for loop, which we can use to conditionally include the All-Stars template after the second article:

{%- for article in pagination.items -%}
    {% include "article.njk" %}

    {% if loop.index === 1 %}
        {% include "components/all-stars.njk" %}
    {% endif %}
{%- endfor -%}

Incidentally, this is what has kept All-Stars hidden until now 😉


Feedbin's API returns a nice big chunk of metadata about each entry, that looks something like this:

    "id": 1682191545,
    "feed_id": 1379740,
    "title": "Peter Kafka @pkafka",
    "author": "Peter Kafka",
    "summary": "In 2009, the big magazine publishers built their own digital service so they wouldn't be cut out by Apple or Google. Now they're selling to Apple.",
    "content": "<div>Content</div>",
    "url": "",
    "extracted_content_url": "",
    "published": "2018-03-12T21:52:16.000000Z",
    "created_at": "2018-03-12T22:55:53.437304Z",
    "images": {
        "original_url": "",
        "size_1": {
            "cdn_url": "",
            "width": 542,
            "height": 304

I thought it would be a nice touch to show images whenever they're available, but not every blog post and article has an image associated with it. By pure coincidence, I was working on All-Stars and staring at a smattering of empty gray placeholders when a link from rolled through:

Generative Placeholders

I had faved it in Feedbin, forgotten(!), and then stumbled across it again at the most opportune moment. As a result, faved entries that dont't bring their own images get a nice, procedurally generated placeholder courtesy of @fourtonfish.

Keeping It Fresh

That's the long and short of the All-Stars implementation itself. However, it's mostly for nothing if links are only updated when I push a new change to GitLab and trigger a build in Netlify. Who cares about a bunch of stale links I faved a few weeks or months ago?

Netlify gets me off the hook, so to speak, for having to worry about this at all. I generated a new Build Hook in the deployment dashboard that builds the master branch any time it's called. I added the bog-standard cURL command:

curl -X POST -d {}<build_hook_id>

to a script on my trusty old DreamObjects droplet, and created a cron job to run that script every six hours:

0 */6 * * * $HOME/scripts/

That means the random passerby is at most six hours away from finding pipin' hot faves fresh out of the celestial oven.


I'm pretty happy with how it turned out, but the current implementation leaves a couple of little gaps:

  • I can only share from within Feedbin. This felt like enough to get the ball rolling, but I'm working on an enhancement that will let me continue to use Feebin faves and share links I find elsewhere.
  • I love the generative placeholders, but Glitch's built-in sleep feature can make things a bit slow on wake. This is probably okay, given the relatively low traffic this little blog gets 😊
  • The design itself bears more than a passing resemblance to ad dumpsters like Taboola and Outbrain, which is basically mortifying. I'll need to fiddle with things to make it look less skeezy.
  • I'm trying to get away from relying on a shared VPS. I haven't found a good cron-as-a-Service replacement yet, but if you have one you like please let me know!


So that's All-Stars. Thanks for the inspiration all these years, Keep on favin' ★

Wheels Up

Howdy! My name is Ashur, and my pronouns are he/him/his. This [gestures unnecessarily] is my blog.

My goal here, broadly speaking, is to take the extra legroom afforded by a site of my own to think longer, slower thoughts than I do on Twitter. (They may not be better, mind you, but they could hardly be worse.)

What to Expect

I love building command-line tools, for work and for fun. (It’s quieter in the terminal, I think, than the buzzing, honking, unending chaos of an internet we’ve clogged with commerce and surveillance.)

I also tend a small stable of “art” bots in my spare time. They’re great at making me feel creative, if undeservedly so, and I like the idea they might bring a brief blip of joy to someone’s timeline.

I’ve had a few false starts, but a couple of years into futzing around with JavaScript in my spare time, I finally feel like I’m starting to get the hang of things. It feels pretty good.

All of which is to say: you can look forward to—or regret—following along as I fumble my way through the ups and downs of programming and re-learning web development.

Airplane in the clouds
Photo by 贝莉儿 DANIST

That’s it, I think? Maybe I’ll see you around 👋