tagshift: from <template> to a component
tagshift is a small library that does exactly one thing: it turns a <template> into a class. An instance has props and slots, and render() returns a finished element. The first call builds it; every call after that updates the very same element in place.
No dependencies, no build step, no virtual DOM. The template is ordinary HTML that sits right on the page.
Website and playground jwo1f.github.io/tagshift A live demo, a playground where you can edit the template, props and slots, and the whole API on one page.Installation
npm install tagshift
You do not need a bundler for it. It is a single module with no imports of its own, so you can take it straight from a CDN:
import { define } from 'https://cdn.jsdelivr.net/npm/tagshift@1.2.0/src/index.js';
It weighs about a kilobyte and a half, minified and gzipped. The TypeScript types ship alongside and are written by hand — there is no TypeScript in the source itself.
The template stays plain HTML
<template id="user-card">
<article class="card card--${variant}">
<h2>Hello, ${name}!</h2>
<div class="body"><slot>Nothing here yet</slot></div>
<footer><slot name="actions"></slot></footer>
</article>
</template>
There are two mechanisms here, and you already know both.
${name} is a placeholder. It works in text and in attribute values, whitespace inside the braces is fine, and the name follows the same rules as a JavaScript identifier. No conditions, no loops, no expressions — just a name.
<slot> is where content will go later. The rules are the ones Shadow DOM uses: a slot without a name is default, the rest have a name, and whatever is written inside a slot is shown until it is given something else.
The browser does not render the contents of a <template>, so this one stays invisible on the page until it is turned into an element.
From a template to a class
import { define } from 'tagshift';
const UserCard = define(document.getElementById('user-card'));
define() copies the template, so later edits to the <template> itself have no effect on a class that already exists. It also checks, right then, that there is exactly one root element. Whitespace and comments around the root do not count; anything else is an error, and it is caught immediately rather than on the first render:
TemplateError: Template must have exactly one root element, found several
TemplateError: Template must not have text outside its root element
TemplateError: Template root element must not be a <slot>
TemplateError: Template must have exactly one root element, found none
TypeError: define() expects a <template> element
These are the real messages, for the templates <p>a</p><p>b</p>, text <p>a</p>, <slot></slot>, an empty one — and for a plain <div> passed instead of a <template>.
render() always returns the same element
const card = new UserCard({ props: { name: 'Alex', variant: 'compact' } });
card.element; // null — nothing rendered yet
document.body.append(card.render());
<article class="card card--compact">
<h2>Hello, Alex!</h2>
<div class="body">Nothing here yet<!----></div>
<footer><!----></footer>
</article>
The constructor only stores copies of props and slots and draws nothing. The element appears on the first render(). From then on props and slots are plain objects: change what you need and call render() again.
const follow = document.createElement('button');
follow.textContent = 'Follow';
card.props.name = 'Sam';
card.slots.actions = follow;
card.render() === card.element; // true
<article class="card card--compact">
<h2>Hello, Sam!</h2>
<div class="body">Nothing here yet<!----></div>
<footer><button>Follow</button><!----></footer>
</article>
Same element. Which means a handler you attached once with addEventListener survives any number of updates, and focus in an input inside the card does not jump anywhere.
Only what changed gets written
Every place with a placeholder — a text node or an attribute value — remembers its original text. render() recomputes it with the current props and writes it to the DOM only if the result is different. You can check this with a MutationObserver:
const Tile = define(template); // <div class="tile tile--${trend}"><span>${label}</span><strong>${value}</strong></div>
const cpu = new Tile({ props: { label: 'CPU', value: '12%', trend: 'up' } });
document.body.append(cpu.render());
new MutationObserver((records) => { /* print every record */ })
.observe(cpu.element, { subtree: true, characterData: true, attributes: true, childList: true });
cpu.props.value = '48%';
cpu.render();
characterData #text 48%
One changed number, one record. The class untouched, the label untouched, nothing re-created and no trees diffed: there is nothing to diff, since the places are known from the first render.
Values are text
card.props.name = '<b>bold</b>';
card.render();
Hello, <b>bold</b>!
A value goes through String() and is inserted as text — <b> stays a string and never becomes HTML. A missing prop, undefined and null all turn into an empty string:
card.props.variant = null;
card.render();
card.element.getAttribute('class'); // 'card card--'
This is where you see the other side of it: the card-- modifier is left dangling with no value. No error — it is an honest empty string. If “no value” should mean something specific for a class or an attribute, let your code decide that before the value lands in props.
Slots
A slot takes a node, a string, or any iterable of them: an array, a NodeList, a DocumentFragment. A string is inserted as text. Placeholders inside slot content are not expanded — ${...} stays as it is:
card.slots.default = 'Hi, ${name}';
<div class="body">Hi, ${name}<!----></div>
An empty array and no value are different things. [] empties the slot. undefined or delete brings back the fallback — whatever was written inside the <slot>:
card.slots.default = [];
card.render(); // <div class="body"><!----></div>
delete card.slots.default;
card.render(); // <div class="body">Nothing here yet<!----></div>
Nodes are moved, not copied. The same <li> handed to a second component leaves the first one:
const List = define(template); // <ul><slot></slot></ul>
const a = new List();
const b = new List();
a.slots.default = li; a.render();
b.slots.default = li; b.render();
<ul><!----></ul>
<ul><li>one</li><!----></ul>
The <!----> in the output is the slot itself. On the first render each <slot> is replaced with an empty comment, and content goes in front of it. Without that marker, there would be no way to tell where new content belongs once the old content changes. Comments do not affect :empty, so styles that rely on it keep working.
A list is an array of rendered components
There are no loops in a template. A list is put together from smaller components and handed to a slot as an array:
<template id="task"><li class="task task--${state}">${title}</li></template>
<template id="tasks"><ul class="tasks"><slot>No tasks</slot></ul></template>
const tasks = ['Buy bread', 'Call mom', 'Fix the tap']
.map((title) => new Task({ props: { title, state: 'open' } }));
const list = new TaskList();
list.slots.default = tasks.map((task) => task.render());
list.render();
tasks[1].props.state = 'done';
tasks.reverse();
list.slots.default = tasks.map((task) => task.render());
list.render();
<ul class="tasks"><li class="task task--open">Buy bread</li><li class="task task--open">Call mom</li><li class="task task--open">Fix the tap</li><!----></ul>
<ul class="tasks"><li class="task task--open">Fix the tap</li><li class="task task--done">Call mom</li><li class="task task--open">Buy bread</li><!----></ul>
The order changed, the second task got a new class, and the <li> elements are still the same ones — rearranged, not rebuilt. Each task.render() updated its own element, and the slot laid them out in the new order.
Where it breaks
An array changed in place goes unnoticed. A slot is refilled only when its value is no longer the same by Object.is. The same array with a new item in it is still the same array:
const items = [];
card.slots.default = items;
card.render();
items.push('one');
card.render(); // <div class="body"><!----></div>
card.slots.default = [...items];
card.render(); // <div class="body">one<!----></div>
The first render() after push did nothing, and nothing will tell you so. That is exactly why the list example builds a fresh array with map every time. The rule is simple: a slot always gets a new array.
An unknown slot is an error, and the element stays as it was.
card.props.name = 'Nobody';
card.slots.sidebar = 'x';
card.render();
TemplateError: Unknown slot "sidebar"
The template has no sidebar slot. Note that name changed in the same update but never reached the element. All content is checked and resolved first and only then written, so an update that throws does not leave the element half-updated. A slot name declared twice is the same TemplateError.
A slot name with a placeholder is resolved once. <slot name="${region}"> is allowed: placeholders are expanded before slots are looked up. But that happens on the first render, and changing region later will not rename the slot.
When a slot needs markup
A string in a slot is text. If you need a piece of HTML with values inside, there is the html tag for that: it parses the markup into a fragment and escapes every interpolated value first.
import { html } from 'tagshift';
const hint = '"quotes" & <tags>';
card.slots.actions = html`<button title="${hint}">${'<OK>'}</button>`;
card.render();
<button title=""quotes" & <tags>"><OK></button><!---->
The markup in the template literal itself is trusted — you wrote it. Whatever came in through ${} is not.
And if you build markup somewhere else, the same mechanism is available on its own:
import { escape } from 'tagshift';
escape(`<a href="x">Tom & Jerry's</a>`);
// '<a href="x">Tom & Jerry's</a>'
escape() is good for text and for a quoted attribute value. For an unquoted attribute or a URL in href it is not enough — that is not what it is for.
What is not here
No conditions or loops in the template, no event binding, no reactivity. Nobody calls render() for you: you changed props, you call it.
That is the trade. No compiler, no syntax of its own, nothing watching your objects behind your back — so the template stays HTML you can read by eye, and everything that happens to the element happens on the line where you called render(). In exchange, a condition is written in JavaScript before the value goes into props, and a list is an array of components in a slot.
If you need more, you need a framework. If you need a card, a tile or a table row that updates itself in place, a kilobyte and a half will do.
The source is at https://github.com/JWo1F/tagshift, under the MIT license.
Comments 0
No comments yet.