John Michael Swartz

Eleventy Components for the Web Platform

4/7/2025

When I migrated this site from Next.js to Eleventy, one of the first deep-ish dive projects was to recreate the animated headers you can see on the home page (or just above this text). I wanted to pay homage to the San Diego Public Library's computerized catalog in use when I was a child, using the Glass TTY VT220 font.


Progressive Enhancement...

As a departure from the original React version, I also wanted to ensure that it adhered to the most basic principles of progressive enhancement. This is inspired by Eleventy's "HTML first" philosophy.


...and (critical) styling

With this in mind, the first challenge arose owing to specific nature of the terminal typing effect: with Javascript, it should initially display a blinking cursor and then display the text one letter at a time; without Javascript, it should simply display the text normally. In either scenario, I didn't want there to be any flashes of one or the other state due to the loading of stylesheets, scripts, or font faces.

Here are the bits and pieces that have to do with the presence of Javascript: basically, we'll key the "no-js" styles to the presence of an attribute on the body tag of the document. Unsurprisingly, this attribute is removed by a simple script in the head.

<head>
	<script>
		document.body.removeAttribute('no-js');
	</script>
</head>
<body no-js></body>

My WebC markup is as follows:

<span class="terminal"><span></span></span
><span class="hidden"><slot></slot><span class="cursor">_</span></span>

The lack of line breaks is significant. I had to set Prettier's treatment of whitespace to "strict" to maintain this formatting. Any extra whitespace here would affect the spacing of the lettering as they're substituted from the invisible to the visible elements.

The default slot accepts text from the light DOM WebC parser like so (assuming the component is called jms-terminal-text):

<jms-terminal-text>John Michael Swartz</jms-terminal-text>

(Note that I'm not referring to the "light DOM" because WebC is only borrowing Web Component syntax; it does not, by default, create actual Web Components where a custom tag is registered and a shadow root is (optionally) set up.)

The styling which addresses our first priority — hiding the text if js is enabled, showing it if not, without a flash — is

.hidden {
	visibility: hidden;

	:is([no-js], [static]) & {
		visibility: visible;
	}
}

It's worth pointing out that I'm using a few more advanced CSS features here. First I'm using nesting, and the & variable, which stands in for the parent selector. Second, I'm using :is. Taken all together, they form the equivalent of

[no-js] .hidden,
[static] .hidden {
	visibility: visible;
}

As you'll see, the hidden text plays an important role in the actual functioning of this component. Additionally, the static property allows us to make the js-enhanced version behave as though js is disabled.

Finally, here is how I manage my fonts and styles generally throughout the site:

<link href="css/index.css" rel="stylesheet" />
<link rel="preload" href="/fonts/fonts.css" as="style" />
<link
	rel="preload"
	href="/fonts/Glass_TTY_VT220.ttf"
	as="font"
	type="font/ttf"
	crossorigin />
<link
	rel="preload"
	href="/fonts/ABeeZee-Regular.ttf"
	as="font"
	type="font/ttf"
	crossorigin />
<link href="/fonts/fonts.css" rel="stylesheet" webc:keep />
<link :href="getBundleFileUrl('css')" rel="stylesheet" webc:keep />

In WebC, link tags referring to stylesheets are bundled into a default css bucket, which is referenced at the bottom using the shortcode getBundleFileUrl('css'): the original link tags will not appear in the build output.

Since I want to avoid layout shifts and FOUC (flashes of unstyled content) I want to load the custom fonts and any styling relating to them as soon as possible. Therefore, I have a small fonts.css file, which, in addition to the fonts themselves, are given the preload treatment, which tells the browser to load these critical resources sooner than it might otherwise.


Scripting

Getting into the scripting side of things, let's begin with some must-haves:

We've actually already taken care of the first point above: by making the original text visibility: hidden we're establishing the box dimensions of the text that will eventually be visible. This is also why I've included the cursor in the hidden text.

Some abandoned strategies for revealing the text were:

Both of these had issues of one sort or another. What I ended up doing was to remove the invisible character when its corresponding visible character was inserted into the DOM. Here's the full script with inline annotations:

// this script will run once, so a jQuery plugin-like strategy works well here.
// collect up all the terminal text nodes
const terms = document.querySelectorAll('jms-terminal-text');
// iterate through them to initialize
terms.forEach(async el => {
	// as mentioned, do nothing if the static attribute is set
	if (el.hasAttribute('static')) return;

	// get the "typing" speed
	const speed = parseInt(el.getAttribute('speed') || 100);

	// get the visible character node
	const terminal = el.querySelector('.terminal');
	// get the invisible character node
	const hidden = el.querySelector('.hidden');

	// this is text that will be revealed
	let text = hidden.textContent.trim();
	// since we added the cursor for spacing purposes, slice it out
	text = text.slice(0, text.length - 1);
	// replace first hidden char with visible cursor
	hidden.textContent = hidden.textContent.slice(1);

	// since the no-js version already has the cursor
	// we don't add it in the js-enhanced version until now
	// (otherwise no-js users would see two cursors without extra styling)
	const cursor = terminal.querySelector('span');
	cursor.textContent = '_';

	// here's the heart of it: an async for loop
	for (let i = 0; i < text.length; i++) {
		// the span allows us to add a class with styling
		const span = document.createElement('span');
		// the letter class has some nice css transitions to mimic a CRT
		span.classList.add('letter');
		// insert the next visible character
		span.textContent = text[i];
		// remove the corresponding hidden character
		hidden.textContent = hidden.textContent.slice(1);
		// insert the new span/visible char before the cursor
		cursor.before(span);
		// pause before continuing the loop
		await new Promise(resolve =>
			// i randomize the speed a bit to simulate human typing
			setTimeout(resolve, speed * Math.random() * 2)
		);
	}

	// finally remove "hidden" cursor/spaces
	hidden.remove();
	// after working on this so long i decided it looked better if the cursor
	// didn't blink til after all the text was visible
	cursor.classList.add('cursor');
});

(Æsthetic) Styling

Finally, here are the styles which have to do with transitioning the opacity of the letters and the blinking cursor. (Every now and then I'll go in and adjust the keyframe values by hand if I'm in some kind of mood.) Also note the use of word-break: break-all, which prevents words from jumping around due to the presence of the cursor character. In my experience, real terminals also often break lines arbitrarily in the middle of words in this way.

.terminal,
.hidden {
	word-break: break-all;
}

.cursor {
	animation: blink 0.9s infinite;
}

.letter {
	animation: easeIn 0.25s;
}

@keyframes blink {
	0% {
		opacity: 1;
	}
	25% {
		opacity: 0;
	}
	60% {
		opacity: 0.8;
	}
	70% {
		opacity: 0.9;
	}
	75% {
		opacity: 0.95;
	}
	100% {
		opacity: 1;
	}
}

@keyframes easeIn {
	0% {
		opacity: 0.5;
	}
	60% {
		opacity: 0.8;
	}
	70% {
		opacity: 0.9;
	}
	75% {
		opacity: 0.9;
	}
	100% {
		opacity: 1;
	}
}

Conclusion

I'm rather pleased with how this turned out. Initially, I was concerned about coming up with a pattern so that each instance of a WebC component could initialize itself individually, with access to its own containing DOM node. As of this writing, the most practical way to do this by initializing a Web Component. Another option (which I find kludgy and cumbersome) is to pass in a unique id and then dynamically generate the scripts (there will be a unique script block for every instance) to use this id to query for the specific DOM node with that id (see the Eleventy Docs on how to "script a script" in this way).

Finally, here's the complete contents of my WebC file for reference.

<span class="terminal"><span></span></span
><span class="hidden"> <slot></slot><span class="cursor">_</span></span>

<script>
	const terms = document.querySelectorAll('jms-terminal-text');
	terms.forEach(async el => {
		if (el.hasAttribute('static')) return;

		const speed = parseInt(el.getAttribute('speed') || 100);

		const terminal = el.querySelector('.terminal');
		const hidden = el.querySelector('.hidden');

		let text = hidden.textContent;
		// for use in revealing, remove cursor char
		text = text.slice(0, text.length - 1);

		// replace first hidden char with visible cursor
		hidden.textContent = hidden.textContent.slice(1);
		const cursor = terminal.querySelector('span');
		cursor.textContent = '_';

		for (let i = 0; i < text.length; i++) {
			const span = document.createElement('span');
			span.classList.add('letter');
			span.textContent = text[i];
			hidden.textContent = hidden.textContent.slice(1);
			cursor.before(span);
			await new Promise(resolve =>
				setTimeout(resolve, speed * Math.random() * 2)
			);
		}

		// finally remove "hidden" cursor/spaces and visible cursor class to blink
		hidden.remove();
		cursor.classList.add('cursor');
	});
</script>

<style webc:scoped>
	.terminal,
	.hidden {
		word-break: break-all;
	}

	.hidden {
		visibility: hidden;

		:is([no-js], [static]) & {
			visibility: visible;
		}
	}

	.cursor {
		animation: blink 0.9s infinite;
	}

	.letter {
		animation: easeIn 0.25s;
	}

	@keyframes blink {
		0% {
			opacity: 1;
		}
		25% {
			opacity: 0;
		}
		60% {
			opacity: 0.8;
		}
		70% {
			opacity: 0.9;
		}
		75% {
			opacity: 0.95;
		}
		100% {
			opacity: 1;
		}
	}

	@keyframes easeIn {
		0% {
			opacity: 0.5;
		}
		60% {
			opacity: 0.8;
		}
		70% {
			opacity: 0.9;
		}
		75% {
			opacity: 0.9;
		}
		100% {
			opacity: 1;
		}
	}
</style>