back

Templates and todo lists – 12 Days of Web Components

December 14, 2023 – @hawkticehurst

Welcome back to 12 Days of Web Components! A series of interactive blog posts that introduces and explores the world of web components.

This is the post for day two, if you missed day one I encourage you to go back and start there.

Today we’re diving a bit deeper into the basics of web components and will be covering the <template> HTML element and its associated JavaScript API.

Please note: This blog post is filled with interactive code examples. If you’re reading this from an RSS feed, I highly recommend you open the original post.

A sneak peek

Here’s what we’re building today. It’s a tried and true classic –– the todo list.

2 remaining

Templates

You can think of <template> as a storage container for your markup. Any valid HTML fragments that you put inside of this container will not be rendered on the page until you explicitly attach them to the DOM (or Shadow DOM as we’ll discuss tomorrow).

<p>I'm a regular paragraph. You can see me.</p>
<template>
	<p>I'm a paragraph in a template. You can't see me.</p>
</template>

You can use browser dev tools to inspect the below example. You’ll find a <template> element right after the paragraph containing the HTML fragment shown above.

I'm a regular paragraph. You can see me.

Templates provide an easy HTML-based way of storing structured pieces of UI that need to be repeatedly used in a component or page.

These pieces of UI can be accessed via a read-only JavaScript property included on the HTMLTemplateElement interface called content.

const tmpl = document.querySelector('template');
const clone = tmpl.content.cloneNode(true);

There’s two important things to call out here:

1. Use of cloneNode

When copying the content of a template it’s extremely common to use the cloneNode method.

This is a method of the built-in Node interface that, as the name suggests, will return a duplicate of the node on which the method was called. Using cloneNode with a template ensures that you can change and manipulate each copy of a template without affecting others.

The passed-in boolean parameter also controls if any subtrees in the template content should be cloned (i.e. a deep clone). (The answer is usually yes.)

Be aware: Cloning a node copies all of its attributes and their values, including inline listeners. This is especially important for attributes like id which should not be duplicated in a document. If using an id you'll need to modify each template you clone to be unique.

2. tmpl.content returns an HTMLFragment

It’s important to remember that tmpl.content (and tmpl.content.cloneNode) will return an HTMLFragment. This means if you ever want to access and/or manipulate the contents of a template before adding it to the DOM you’ll need to do a bit of extra work.

The easiest thing you can do is use querySelector to find the specific element(s) you want and then directly manipulate them with JavaScript.

const paragraph = clone.querySelector('p');
paragraph.textContent = 'This paragraph has been changed!';

A quick example

To pull this all together, let’s scaffold a tiny vanilla example: A button that, when clicked, will copy the contents of a template and add it to the DOM.

Also, notice that we haven’t mentioned or used web components at all yet. This is one of the cool features of “web component” APIs –– they are browser APIs first and foremost, you are not restricted in how or where they can be used.

<button id="copy">Copy template content</button>
<section id="container"></section>
<template id="tmpl">
	<p>I'm a piece of UI!</p>
</template>

<script type="module">
	const button = document.querySelector('#copy');
	const container = document.querySelector('#container');
	const tmpl = document.querySelector('#tmpl');

	button.addEventListener('click', () => {
		const clone = tmpl.content.cloneNode(true);
		container.appendChild(clone);
	});
</script>

Using templates in web components

In a web component context, templates are almost always demonstrated and used as a tool for creating the underlying markup of a JavaScript Web Component (note: if you don’t know what a JavaScript Web Component is see the post from day one). This, however, requires Shadow DOM so we’ll leave that conversation for tomorrow.

Instead, we’re going to talk about how templates can be used along with HTML web components to help you avoid manually creating and configuring DOM nodes.

And yes.. for those paying attention, this does conflict with what I said yesterday about HTML web components “only using the custom elements API and [nothing else from] the ‘web components’ bucket, such as shadow DOM or templates.”

As I said above, web component APIs can be mixed and matched as needed. The difference between HTML and JavaScript Web Components exists not so much as two discrete entities but rather as living along a spectrum.

A todo list (the hard way)

So let’s build our todo list! To demonstrate the power of templates, I’ll first show you what it looks like to do this without the <template> element. Each todo item will be manually created and configured using regular JavaScript DOM manipulation APIs.

Given the following starter HTML…

<todo-list>
	<input type="text" placeholder="Add todo item" />
	<section class="todos"></section>
</todo-list>

…we’ll add a todo item anytime the Enter key is pressed in the text field.

class TodoList extends HTMLElement {
	constructor() {
		super();
		this.input = this.querySelector('input');
		this.todos = this.querySelector('.todos');
		this.input.addEventListener('keydown', this.addTodo);
	}
	addTodo = (event) => {
		if (event.key !== 'Enter') return;
		// Create elements required to make a todo item
		const todo = document.createElement('div');
		const label = document.createElement('label');
		const checkbox = document.createElement('input');
		// Configure elements
		const todoText = event.target.value;
		const uid = todoText.toLowerCase().replace(/\s+/g, '-');
		todo.classList.add('todo');
		checkbox.type = 'checkbox';
		checkbox.setAttribute('id', uid);
		label.setAttribute('for', uid);
		label.textContent = todoText;
		// Append checkbox and label inside todo item
		todo.appendChild(checkbox);
		todo.appendChild(label);
		// Add todo item to the todos container
		this.todos.appendChild(todo);
		// Clear text field
		event.target.value = '';
	};
}
customElements.define('todo-list', TodoList);

It’s just come Classic Vanilla DOM Manipulation™. The only potentially fancy thing to note is that we create a unique identifier (uid) using the value we get from the text field.

const uid = todoText.toLowerCase().replace(/\s+/g, '-');

This will turn any space-separated string into a lowercase string separated by dashes. We need this uid to implement proper accessibility semantics by creating a unique association between the todo item checkbox and label.

checkbox.setAttribute('id', uid);
label.setAttribute('for', uid);

I’m sure there’s an even smarter/better way to create this association dynamically, but that’s what I came up with for today.

Unique identifiers aside, I hope you get the big idea. Creating UI using vanilla DOM manipulation is fairly straightforward but not at all fun to write.

A todo list (the template way)

Let’s create a new todo list that instead includes a <template> tag as a child of our custom element.

<todo-list>
	<input type="text" placeholder="Add todo item" />
	<section class="todos"></section>
	<template>
		<div class="todo">
			<input type="checkbox" />
			<label></label>
		</div>
	</template>
</todo-list>

This template can then be referenced and cloned within our component class.

class TodoList extends HTMLElement {
	constructor() {
		super();
		this.input = this.querySelector('input');
		this.todos = this.querySelector('.todos');
		// New! Get reference to template
		this.template = this.querySelector('template');
		this.input.addEventListener('keydown', this.addTodo);
	}
	addTodo = (event) => {
		if (event.key !== 'Enter') return;
		// Clone template
		const tmpl = this.template.content.cloneNode(true);
		// Get references to checkbox and label inside template
		const checkbox = tmpl.querySelector('input');
		const label = tmpl.querySelector('label');
		// Insert data/content into template
		const todoText = event.target.value;
		const uid = todoText.toLowerCase().replace(/\s+/g, '-');
		checkbox.setAttribute('id', uid);
		label.setAttribute('for', uid);
		label.textContent = todoText;
		// Add todo to the todos container
		this.todos.appendChild(tmpl);
		// Clear text field
		event.target.value = '';
	};
}
customElements.define('todo-list', TodoList);

A solid improvement! With templates, we can avoid all of the manual DOM creation and configuration from earlier.

Admittedly, the above template code still isn’t the most fun to write or look at though. It mainly comes down to the fact that there’s some content (i.e. the todo item text and for/id attributes) that needs to be dynamically added when a todo item is created.

Browser-native templating syntax is what would complete this picture, but that doesn’t exist… yet. A new browser API called DOM Parts is in the works that aims to provide a better mechanism for updating templates with dynamic content.

This might draw a common response I see to web components, however –– they’re incomplete and browser support takes too long. Ugh.

So to close out I want to talk about how I’ve come to think about new web component features.

That also wraps up all the important parts of our todo list I wanted to talk about today. Below (in the collapsible view) you can find the code for a completed <todo-list> component.

Completed todo list code
<todo-list>
	<input type="text" placeholder="Add todo item">
	<section class="todos">
		<div class="todo">
			<input id="pick-up-groceries" type="checkbox">
			<label for="pick-up-groceries">Pick up groceries</label>
		</div>
		<div class="todo">
			<input id="go-on-a-walk" type="checkbox">
			<label for="go-on-a-walk">Go on a walk</label>
		</div>
	</section>
	<p><span id="remaining">2</span> remaining</p>
	<button>Clear completed</button>
	<template>
		<div class="todo">
			<input type="checkbox">
			<label></label>
		</div>
	</template>
</todo-list>

<script type="module">
	class TodoList extends HTMLElement {
		constructor() {
			super();
			this.input = this.querySelector('input');
			this.todos = this.querySelector('.todos');
			this.button = this.querySelector('button');
			this.template = this.querySelector('template');
			this.remaining = this.querySelector('#remaining');
			this.input.addEventListener('keydown', this.addTodo);
			this.todos.addEventListener('change', this.updateRemainder);
			this.button.addEventListener('click', this.clearTodos);
		}
		addTodo = (event) => {
			if (event.key !== "Enter") return;
			const todoText = event.target.value;
			const tmpl = this.template.content.cloneNode(true);
			const checkbox = tmpl.querySelector('input');
			const label = tmpl.querySelector('label');
			const uid = todoText.toLowerCase().replace(/\s+/g, '-');
			checkbox.setAttribute("id", uid);
			label.setAttribute("for", uid);
			label.textContent = todoText;
			this.todos.appendChild(tmpl);
			event.target.value = "";
			this.updateRemainder();
		}
		clearTodos = () => {
			const completed = this.todos.querySelectorAll('input[type="checkbox"]:checked');
			for (const todo of completed) {
				this.todos.removeChild(todo.parentNode);
			}
			this.updateRemainder();
		}
		updateRemainder = () => {
			const unchecked = this.todos.querySelectorAll('input[type="checkbox"]:not(:checked)');
			this.remaining.textContent = unchecked.length;
		}
	}
	customElements.define('todo-list', TodoList);
</script>

<style>
	todo-list button,
	todo-list input {
		font-family: inherit;
		font-size: inherit;
		font-weight: 500;
		color: white;
		background-color: rgb(95, 85, 236, 0.5);
		border: 2px solid rgb(95, 85, 236);
		border-radius: 8px;
		padding: 6px 10px;
	}
	todo-list button {
		font-size: 0.9rem;
		padding: 4px 10px;
	}
	todo-list button:hover,
	todo-list button:active {
		cursor: pointer;
		background-color: rgb(95, 85, 236, 0.7);
	}
	todo-list button:focus-visible,
	todo-list input:focus-visible {
		outline: solid 2px white;
		outline-offset: 2px;
	}
	todo-list input::placeholder {
		color: #999;
	}
	todo-list .todos {
		margin: 20px 0;
		display: flex;
		flex-direction: column;
		gap: 10px;
		margin-bottom: 0;
	}
	todo-list .todo {
		display: flex;
		align-items: center;
		gap: 6px;
	}
	todo-list label {
		user-select: none;
		cursor: pointer;
	}
	todo-list input[type='checkbox'] {
		--checkbox-size: 24px;
		-webkit-appearance: none;
		appearance: none;
		margin: 0;
		width: var(--checkbox-size);
		height: var(--checkbox-size);
		border: 2px solid rgb(95, 85, 236);
		border-radius: 6px;
		display: grid;
		place-content: center;
		cursor: pointer;
	}
	todo-list input[type='checkbox']::before {
		--checkmark-size: 12px;
		content: '';
		box-shadow: inset var(--checkmark-size) var(--checkmark-size)
			white;
		background-color: CanvasText;
		width: var(--checkmark-size);
		height: var(--checkmark-size);
		clip-path: polygon(
			12.08% 50.81%,
			0.24% 67.73%,
			42.23% 97.13%,
			99.25% 15.68%,
			81.01% 2.91%,
			35.84% 67.44%
		);
		transform: scale(0);
		transition: transform 100ms ease-in-out;
	}
	todo-list input[type='checkbox']:checked::before {
		transform: scale(1);
	}
	todo-list input[type='checkbox']:focus-visible {
		outline: none;
		border-color: white;
	}
	todo-list .todo:has(input[type="checkbox"]:checked) label {
		text-decoration: line-through;
	}
</style>

How to think about new web component features

Just like any other browser API, new web component APIs (read: features) must go through their own standards process.

A combination of various community groups and browser vendor teams are continuously working together to propose new APIs, specify and standardize how they should work, and then get them implemented across browsers.

This. process. takes. time.

Historical context: This is, in large part, the reason that web components, despite being introduced back in the early 2010s, have taken so long to increase in adoption. It took almost a decade to get the first set of web component APIs standardized and implemented across all major browsers.

Lucky for us, this process is starting to move quicker nowadays. Efforts like Interop 2022 and Interop 2023 have seen all major browser vendors working together to solve browser compatibility issues and are to a larger extent hinting at a new state of the web where browser vendors are committing to be better about implementing new and existing APIs in a timely and consistent way.

Even with a quicker feedback loop, new web component features will (for all intents and purposes) almost always take longer to ship than new web framework features. This is a reality that is often frustrating to some people, but there’s a really important tradeoff being made. The upfront cost of standards work means that any API added to browsers is committing to a contract of backward compatibility defined by the web –– that is to say, it’s here to stay.

As others have said before me, the idea is that web components APIs should outlast entire web frameworks.

In the next episode…

Thanks for stopping by! In the next post, we continue deeper into the basics of web components with Shadow DOM.

Follow me on Mastodon for updates and see you tomorrow!