The HTML template tag: write markup once, stamp it out with JavaScript

A <template> is HTML the browser reads but does not show. Copy its content with cloneNode(true), fill in the data, and append it as many times as you need.

The <template> tag holds HTML that the browser parses but does not show. Nothing inside it is drawn, its images do not load and its scripts do not run.

To use it, copy its content with template.content.cloneNode(true), fill the copy with data and append it to the page.

Try it. The three rows below come from a JavaScript array and one template. Add your own items and remove any row.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rows from a template</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { display: flex; gap: 8px; margin-bottom: 12px; }
  input { flex: 1; min-width: 0; font: 15px system-ui, sans-serif; padding: 8px 10px; border: 1px solid #c9ced6; border-radius: 8px; }
  button { font: 14px system-ui, sans-serif; padding: 8px 12px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; cursor: pointer; }
  form button { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
  li { display: flex; align-items: center; gap: 10px; background: #fff; border-radius: 10px; padding: 8px 10px 8px 14px; }
  .name { flex: 1; font-weight: 600; }
  .qty { font-size: 13px; color: #6b7280; }
  .remove { padding: 4px 9px; font-size: 13px; }
  #count { font-size: 13px; color: #6b7280; margin: 10px 2px 0; }
</style>
</head>
<body>
<form id="add">
  <input id="item" placeholder="Add an item, e.g. Coffee" autocomplete="off">
  <button>Add</button>
</form>

<ul id="list"></ul>
<p id="count"></p>

<!-- Not shown on the page. It is a stamp for one row. -->
<template id="row">
  <li>
    <span class="name"></span>
    <span class="qty"></span>
    <button class="remove" type="button">Remove</button>
  </li>
</template>

<script>
  const items = [
    { name: 'Milk', qty: 2 },
    { name: 'Eggs', qty: 12 },
    { name: 'Bread', qty: 1 },
  ];
  const list = document.querySelector('#list');
  const tpl = document.querySelector('#row');

  function addRow(item) {
    const row = tpl.content.cloneNode(true);             // copy the template's content
    row.querySelector('.name').textContent = item.name;  // fill it with data
    row.querySelector('.qty').textContent = 'x ' + item.qty;
    list.append(row);                                    // now it is on the page
  }

  function showCount() {
    document.querySelector('#count').textContent = list.children.length + ' rows on the page';
  }

  items.forEach(addRow);
  showCount();

  document.querySelector('#add').addEventListener('submit', (e) => {
    e.preventDefault();
    const input = document.querySelector('#item');
    if (!input.value.trim()) return;
    addRow({ name: input.value.trim(), qty: 1 });
    input.value = '';
    showCount();
  });

  // one listener on the list handles every Remove button, old and new
  list.addEventListener('click', (e) => {
    if (!e.target.matches('.remove')) return;
    e.target.closest('li').remove();
    showCount();
  });
</script>
</body>
</html>
One template, one row per item in the array. Edit the code and the example reruns.

The row markup is written once, in HTML, where it is easy to read and style. JavaScript only copies it and puts text into the empty spots.

How template, content and cloneNode fit together

A template has two parts: the <template> element itself, which stays hidden in the page, and its content property. The content is a document fragment, a small separate tree that holds the markup.

The markup waits in the template, is copied and filled, and only draws once it is appended.
The markup waits in the template, is copied and filled, and only draws once it is appended.
  1. Write the markup. Put one row inside <template id="row">, with empty spots for the data.
  2. Clone the content. tpl.content.cloneNode(true) returns a fresh fragment. The true copies all the children, not only the top element.
  3. Fill the copy. Call querySelector on the copy and set textContent on each spot.
  4. Append it. Appending the fragment moves its children onto the page.
<ul id="list"></ul>

<template id="row">
  <li><span class="name"></span> <span class="qty"></span></li>
</template>

<script>
  const tpl = document.querySelector('#row');
  const list = document.querySelector('#list');

  [{ name: 'Milk', qty: 2 }, { name: 'Eggs', qty: 12 }].forEach((item) => {
    const row = tpl.content.cloneNode(true);
    row.querySelector('.name').textContent = item.name;
    row.querySelector('.qty').textContent = 'x ' + item.qty;
    list.append(row);
  });
</script>

document.importNode(tpl.content, true) does the same job. Either one is fine.

Filling each copy with data

Set values with textContent, not by building an HTML string. textContent shows whatever the user typed as plain text, so typing <b>Coffee</b> in the first example adds a row that reads <b>Coffee</b>. innerHTML would parse it as markup instead.

Fill the copy before you append it. After list.append(row), the fragment is empty, because its children moved to the list. A row.querySelector(...) on the next line returns null.

If you need the new element afterwards, keep a reference first:

const row = tpl.content.cloneNode(true);
const li = row.querySelector('li');  // grab it while it is in the fragment
list.append(row);
li.classList.add('new');             // li is now on the page

For buttons inside the rows, one listener on the list catches clicks from every row, including rows added later. The first example removes rows this way with addEventListener and closest('li').

What inert means: nothing draws, loads or runs

The HTML standard calls template content inert. The parser builds it into elements, but they belong to a separate document with no window. That is why nothing happens until a copy is inserted.

The template below holds an image and a <script>. Watch the counters as you insert copies.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Template content is inert</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .btns { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
  button { font: 14px system-ui, sans-serif; padding: 8px 12px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; cursor: pointer; }
  #stamp { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  .nums { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
  .num { min-width: 0; background: #fff; border-radius: 8px; padding: 8px 4px; text-align: center; font-size: 12.5px; line-height: 1.3; overflow-wrap: anywhere; }
  .num b { display: block; font-size: 26px; }
  .num code { font-size: 11px; }
  #page { display: flex; flex-wrap: wrap; gap: 8px; min-height: 60px; padding: 10px; border: 2px dashed #c9ced6; border-radius: 10px; background: #fff; }
  #page:empty::before { content: 'Nothing here yet. The template is on the page, but hidden.'; color: #6b7280; font-size: 13px; }
  .badge { display: flex; align-items: center; gap: 6px; padding: 5px 9px 5px 5px; border-radius: 99px; background: #eef2ff; font-size: 13px; }
  .badge img { width: 28px; height: 28px; border-radius: 50%; }
</style>
</head>
<body>
<div class="btns">
  <button id="stamp">Clone and insert</button>
  <button id="clear">Clear</button>
</div>

<div class="nums">
  <div class="num"><b id="inTpl">0</b>images in<br><code>template.content</code></div>
  <div class="num"><b id="inDoc">0</b>images in<br><code>document</code></div>
  <div class="num"><b id="runs">0</b>times the script<br>inside ran</div>
</div>

<div id="page"></div>

<template id="badge">
  <div class="badge">
    <img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'%3E%3Crect width='28' height='28' fill='%2316a34a'/%3E%3Ccircle cx='14' cy='11' r='5' fill='%23fff'/%3E%3Cpath d='M5 26c1-6 5-8 9-8s8 2 9 8' fill='%23fff'/%3E%3C/svg%3E">
    <span>Stamped</span>
  </div>
  <script>
    // runs once per inserted copy, never while it sits in the template
    window.stampRuns = (window.stampRuns || 0) + 1;
    document.querySelector('#runs').textContent = window.stampRuns;
  </script>
</template>

<script>
  const tpl = document.querySelector('#badge');
  const page = document.querySelector('#page');

  function count() {
    // document.querySelectorAll does not look inside a template
    document.querySelector('#inDoc').textContent = document.querySelectorAll('img').length;
    document.querySelector('#inTpl').textContent = tpl.content.querySelectorAll('img').length;
  }

  document.querySelector('#stamp').addEventListener('click', () => {
    page.append(tpl.content.cloneNode(true));
    count();
  });

  document.querySelector('#clear').addEventListener('click', () => {
    page.replaceChildren();
    count();
  });

  count();
</script>
</body>
</html>
The image and script inside the template do nothing until a copy is inserted. Each copy runs the script once.
In template.content A copy appended to the page
Drawn on screen No Yes
Images Not loaded Loaded
Scripts Not run Run once per copy
Found by document.querySelector No Yes
Found by tpl.content.querySelector Yes No

This makes a template a safe place to keep markup you may never use, such as an empty-state message or a row for a rare case. It costs no image downloads until it is needed.

Clearing the inserted copies does not undo their scripts. The counter in the example stays where it was, because each script ran when it was inserted.

Clone template.content, not the template

An easy mistake is one missing word. tpl.cloneNode(true) copies the <template> element itself, and the copy is just another hidden template.

Cloning the template adds more hidden templates. Cloning its content adds real elements.
Cloning the template adds more hidden templates. Cloning its content adds real elements.

No error appears. In the browser's developer tools the copies show up as <template> elements with the markup tucked inside them, but the page stays blank. Add .content and the rows show up.

The same split explains the second surprise. document.querySelector('.name') never finds anything inside a template, because the template's children are not in the page's tree. Search tpl.content instead. Read more on how the page's tree works in the DOM in JavaScript and querySelector.

Slots: filling a custom element from the outside

Templates are also how web components usually carry their inner markup. A custom element can have a shadow root, a private DOM tree attached to it. Page CSS does not reach inside, and the element's own CSS does not leak out.

A <slot> in that shadow root is a placeholder. The element's normal children, written on the page, are shown in the slots.

Children with slot="name" go to the named slot, children without a slot attribute go to the default slot, and an empty slot shows its fallback.
Children with slot="name" go to the named slot, children without a slot attribute go to the default slot, and an empty slot shows its fallback.
  • Named slot: <slot name="role"> shows the child that has slot="role".
  • Default slot: a <slot> without a name takes every child that has no slot attribute.
  • Fallback content: whatever is written inside a slot shows only when nothing fills it. <slot name="role">No role yet</slot> reads "No role yet" until a role is given.

The slotted children stay in the page's DOM. They only appear inside the card.

A finished example: a user-card element

This <user-card> has named slots for the avatar, name and role, a default slot for the bio, and fallback text for each. The second and third cards leave some slots empty. Add a card with a name only and every other slot falls back.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>user-card custom element with slots</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
  form { display: flex; gap: 8px; margin-top: 12px; }
  input { flex: 1; min-width: 0; font: 15px system-ui, sans-serif; padding: 8px 10px; border: 1px solid #c9ced6; border-radius: 8px; }
  button { font: 14px system-ui, sans-serif; padding: 8px 12px; border-radius: 8px; border: 1px solid #1d4ed8; background: #1d4ed8; color: #fff; cursor: pointer; }
  /* page styles still reach slotted content: it lives in the page */
  user-card p { margin: 0; }
</style>
</head>
<body>

<div class="cards" id="cards">
  <user-card>
    <svg slot="avatar" viewBox="0 0 48 48"><rect width="48" height="48" fill="#7c3aed"/><text x="24" y="31" font-size="20" text-anchor="middle" fill="#fff" font-family="system-ui">MK</text></svg>
    <span slot="name">Mina Kang</span>
    <span slot="role">Product designer</span>
    <p>Runs the Thursday design review.</p>
  </user-card>

  <user-card>
    <span slot="name">Leo Park</span>
    <span slot="role">Support lead</span>
  </user-card>

  <user-card>
    <span slot="name">Sam Ito</span>
    <p>Joined this week.</p>
  </user-card>
</div>

<form id="add">
  <input id="who" placeholder="Name for a new card" autocomplete="off">
  <button>Add card</button>
</form>

<!-- The card's inside: private markup and styles for the shadow root -->
<template id="user-card-tpl">
  <style>
    :host { display: flex; gap: 12px; padding: 14px; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgba(0, 0, 0, .08); }
    .avatar { flex: none; width: 48px; height: 48px; border-radius: 50%; overflow: hidden; background: #d5d9e0; display: grid; place-items: center; color: #6b7280; font-size: 11px; line-height: 1.1; text-align: center; }
    ::slotted(svg) { width: 48px; height: 48px; display: block; }
    .name { font-weight: 700; }
    .role { font-size: 13px; color: #1d4ed8; margin: 2px 0 6px; }
    .bio { font-size: 13.5px; color: #374151; }
  </style>
  <div class="avatar"><slot name="avatar">no photo</slot></div>
  <div>
    <div class="name"><slot name="name">Unnamed</slot></div>
    <div class="role"><slot name="role">No role yet</slot></div>
    <div class="bio"><slot>No bio yet.</slot></div>
  </div>
</template>

<script>
  class UserCard extends HTMLElement {
    constructor() {
      super();
      const tpl = document.querySelector('#user-card-tpl');
      this.attachShadow({ mode: 'open' })            // a private DOM for this card
          .append(tpl.content.cloneNode(true));      // filled from the template
    }
  }
  // the name must contain a hyphen, and can be defined only once
  if (!customElements.get('user-card')) customElements.define('user-card', UserCard);

  document.querySelector('#add').addEventListener('submit', (e) => {
    e.preventDefault();
    const input = document.querySelector('#who');
    if (!input.value.trim()) return;
    const card = document.createElement('user-card');
    const name = document.createElement('span');
    name.slot = 'name';
    name.textContent = input.value.trim();   // textContent, so a typed <b> stays text
    card.append(name);                        // no role, no avatar, no bio: fallbacks show
    document.querySelector('#cards').append(card);
    input.value = '';
  });
</script>
</body>
</html>
A custom element with Shadow DOM. Its markup and styles come from a template. Empty slots show fallback text.

The JavaScript part is short:

class UserCard extends HTMLElement {
  constructor() {
    super();
    const tpl = document.querySelector('#user-card-tpl');
    this.attachShadow({ mode: 'open' })
        .append(tpl.content.cloneNode(true));
  }
}
customElements.define('user-card', UserCard);
  • class ... extends HTMLElement makes a new kind of element. super() must come first in the constructor.
  • attachShadow({ mode: 'open' }) creates the private tree. open lets outside code reach it as card.shadowRoot.
  • customElements.define ties the tag name to the class. Every <user-card> on the page, including ones added later, gets upgraded.

Styling across the shadow boundary

  • A <style> inside the shadow root styles only the card. :host targets the <user-card> element itself.
  • Page CSS does not match elements inside the shadow root. Inherited properties such as color and font-family still flow in, and so do CSS variables.
  • Slotted children are styled by the page, because they live there. From inside, ::slotted(svg) styles them. It only matches the slotted element itself, not elements nested inside it.

Declarative Shadow DOM, briefly

A shadow root can also be written in plain HTML, with no JavaScript:

<user-card>
  <template shadowrootmode="open">
    <slot name="name">Unnamed</slot>
  </template>
  <span slot="name">Mina Kang</span>
</user-card>

The parser turns that template into the element's shadow root as the page loads. It only happens when the HTML parser reads the page.

Setting the same string with innerHTML leaves an ordinary template behind and no shadow root. For pages built in the browser, the attachShadow version above is the one to reach for.

When it does not work

What you see Cause Fix
Nothing appears, and there is no error Cloned the template instead of its content tpl.content.cloneNode(true)
querySelector returns null for an element in the template document does not search template content tpl.content.querySelector(...)
row.querySelector returns null after appending Appending emptied the fragment Fill the copy before append, or keep a reference
Page CSS does not style the inside of a custom element Shadow DOM keeps page selectors out Style it from the shadow <style>, or pass values with CSS variables
::slotted(p span) matches nothing ::slotted only takes the slotted element Style nested parts from the page CSS
SyntaxError from customElements.define The name has no hyphen, such as card Use a name like user-card
NotSupportedError: name already used define ran twice, for example the script is included twice Check customElements.get('user-card') first
A slot shows its fallback although the child is there The slot value does not match the slot's name Make the two strings match exactly

Templates and custom elements are built by the script when the page opens, so a screenshot shows only one moment of them. Sent as an .html attachment, the file may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can add rows and cards themselves. If you change the code later, the same link shows the new version.

Questions people ask

What does the template tag do in HTML?

It holds a piece of HTML that the browser parses but does not display. Its images do not load and its scripts do not run. JavaScript copies the markup from template.content and inserts the copy, which then behaves like any other HTML on the page.

How is a template different from a hidden div?

A hidden div is a normal part of the page: its elements are found by document.querySelector, and its scripts have already run. A template's content lives in a separate document fragment, so the page's queries do not reach it and nothing inside it runs until a copy is inserted.

Do I need Shadow DOM or web components to use template?

No. Cloning template.content into an ordinary list, table or grid works on its own, as the first example shows. Templates are also a convenient way to hold a custom element's shadow markup, but that is optional.

Do slots work without Shadow DOM?

No. A <slot> only places content when it sits inside a shadow root. Written directly in the page, a slot element does not pull any children into it.

Can I set a template's content with innerHTML?

Yes. Setting innerHTML on a <template> element puts the parsed markup into template.content, so you can clone it the same way. Reading innerHTML returns that content as a string.

Keep reading