Preact in one HTML file: a score keeper you can share

Preact is a small JavaScript UI library with the same modern API as React. With an import map and htm, it runs from a single HTML file, no install and no build.

Preact here means the JavaScript UI library, and "scores" means a score counter, scoreboard or leaderboard built with it. Its site describes Preact as a "Fast 3kB alternative to React with the same modern API".

You do not need npm or a build tool. The Preact guide says it "is packaged to be used directly in the browser, and doesn't require any build or tools." One HTML file with an import map is enough.

Try it first. Press the buttons.

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>Preact score counter</title>
<style>
  body { margin: 0; padding: 24px 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .card { max-width: 320px; margin: 0 auto; padding: 20px; border-radius: 14px; background: #fff;
          box-shadow: 0 6px 20px rgba(0, 0, 0, .1); text-align: center; }
  .score { font-size: 64px; font-weight: 800; margin: 8px 0 14px; }
  button { font: inherit; font-size: 18px; min-width: 64px; padding: 10px 16px; margin: 0 4px;
           border: 0; border-radius: 10px; background: #2563eb; color: #fff; cursor: pointer; }
  button.minus { background: #e5e7eb; color: #111827; }
</style>

<!-- 1. Tell the browser where "preact" and "htm/preact" live. It must come before the module script. -->
<script type="importmap">
{
  "imports": {
    "preact": "https://cdn.jsdelivr.net/npm/preact@11.0.0/dist/preact.mjs",
    "preact/hooks": "https://cdn.jsdelivr.net/npm/preact@11.0.0/hooks/dist/hooks.mjs",
    "htm": "https://cdn.jsdelivr.net/npm/htm@3.1.1/dist/htm.module.js",
    "htm/preact": "https://cdn.jsdelivr.net/npm/htm@3.1.1/preact/index.module.js"
  }
}
</script>
</head>
<body>
<div id="app"></div>

<!-- 2. type="module" lets the script use import. -->
<script type="module">
  import { render } from 'preact';
  import { useState } from 'preact/hooks';
  import { html } from 'htm/preact';

  function Score() {
    const [score, setScore] = useState(0);
    return html`
      <div class="card">
        <b>Home team</b>
        <div class="score" id="score">${score}</div>
        <button class="minus" onClick=${() => setScore(score - 1)}>-1</button>
        <button onClick=${() => setScore(score + 1)}>+1</button>
      </div>`;
  }

  // 3. Draw the component into #app.
  render(html`<${Score} />`, document.getElementById('app'));
</script>
</body>
</html>
A score counter in one HTML file. Preact 11.0.0 and htm 3.1.1 load from jsDelivr. Edit the code and the example reruns.

The smallest working page

Every single-file Preact page has the same three parts:

  1. An import map. It tells the browser which URL to fetch when a script says import ... from 'preact'.
  2. A module script. Only <script type="module"> can use import.
  3. A render() call. It draws a component into an empty element on the page.
<script type="importmap">
{
  "imports": {
    "preact": "https://cdn.jsdelivr.net/npm/preact@11.0.0/dist/preact.mjs",
    "preact/hooks": "https://cdn.jsdelivr.net/npm/preact@11.0.0/hooks/dist/hooks.mjs",
    "htm": "https://cdn.jsdelivr.net/npm/htm@3.1.1/dist/htm.module.js",
    "htm/preact": "https://cdn.jsdelivr.net/npm/htm@3.1.1/preact/index.module.js"
  }
}
</script>

Then the component itself:

<div id="app"></div>
<script type="module">
  import { render } from 'preact';
  import { useState } from 'preact/hooks';
  import { html } from 'htm/preact';

  function Score() {
    const [score, setScore] = useState(0);
    return html`<button onClick=${() => setScore(score + 1)}>${score}</button>`;
  }

  render(html`<${Score} />`, document.getElementById('app'));
</script>

Why the import map comes first

'preact' is a bare name, not a URL. The import map turns it into one. MDN says the map "must be declared and processed before any <script> elements that import modules using specifiers declared in the map."

The bare name "preact" is looked up in the import map and becomes one pinned file on the CDN.
The bare name "preact" is looked up in the import map and becomes one pinned file on the CDN.

The map is also what keeps Preact to a single copy. The hooks file and htm/preact both import 'preact' by name, so they get the same file your script gets.

The Preact guide is firm on this: "Preact must be used only as a singleton with only a single copy included in your app."

MDN says the map "must be a valid JSON object", so leave no trailing comma after the last entry.

htm instead of JSX

JSX is the HTML-like syntax in code such as <Score />. The Preact guide notes that JSX "requires a build step". In a plain script, the browser hits the < and throws a SyntaxError.

JSX stops the script with a SyntaxError. The same line in htm runs as written.
JSX stops the script with a SyntaxError. The same line in htm runs as written.

htm is the replacement the guide suggests. Its README calls it "JSX-like syntax in plain JavaScript - no transpiler necessary." It uses tagged templates, which the Preact guide notes are "supported in all modern browsers". The changes from JSX are small:

JSX htm
<Score /> <${Score} />
{score} ${score}
onClick={add} onClick=${add}
<div {...props}> <div ...${props}>
</Score> </${Score}> or <//>

Watch the component tag. If you write <Score /> inside htm, there is no error. Preact creates an empty element named Score and the component never runs.

If you would rather keep JSX, that means a project with a bundler. Host a React app as a page covers publishing that kind of build output.

State: how a click changes the score

useState comes from preact/hooks. The guide describes it: when invoked it "returns an array of two variables. The first being the current state and the second being the setter for our state."

Calling the setter runs the component again, and Preact changes the page to match what it returned.
Calling the setter runs the component again, and Preact changes the page to match what it returned.

Calling the setter triggers "a rerender starting from the component where that useState has been used." The component returns a new description of the page. Preact compares it with the DOM and updates the DOM to match.

One trap: the setter compares the old and new value with Object.is. Pushing into an array and passing the same array back changes nothing on screen. Always hand it a new array or object.

One component, many teams

Components take inputs, called props. Here the Team component only draws. The two scores live in Board, which passes each one down with a function to change it.

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>Preact: one component, two teams</title>
<style>
  body { margin: 0; padding: 18px 12px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .board { display: flex; gap: 10px; justify-content: center; }
  .team { flex: 1 1 0; max-width: 200px; padding: 14px 8px; border-radius: 14px; background: #fff;
          box-shadow: 0 6px 20px rgba(0, 0, 0, .1); text-align: center; border-top: 6px solid var(--c); }
  .score { font-size: 52px; font-weight: 800; margin: 4px 0 10px; }
  button { font: inherit; font-size: 16px; padding: 8px 12px; margin: 2px; border: 0; border-radius: 9px;
           background: var(--c); color: #fff; cursor: pointer; }
  .lead { margin: 14px 0 8px; text-align: center; font-weight: 600; color: #374151; }
  .reset { display: block; margin: 0 auto; background: #e5e7eb; color: #111827; }
</style>
<script type="importmap">
{
  "imports": {
    "preact": "https://cdn.jsdelivr.net/npm/preact@11.0.0/dist/preact.mjs",
    "preact/hooks": "https://cdn.jsdelivr.net/npm/preact@11.0.0/hooks/dist/hooks.mjs",
    "htm": "https://cdn.jsdelivr.net/npm/htm@3.1.1/dist/htm.module.js",
    "htm/preact": "https://cdn.jsdelivr.net/npm/htm@3.1.1/preact/index.module.js"
  }
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module">
  import { render } from 'preact';
  import { useState } from 'preact/hooks';
  import { html } from 'htm/preact';

  // Team only draws. The score lives in Board and comes in as a prop.
  function Team({ name, color, score, onAdd }) {
    return html`
      <div class="team" style=${{ '--c': color }}>
        <b>${name}</b>
        <div class="score">${score}</div>
        <button onClick=${() => onAdd(1)}>+1</button>
        <button onClick=${() => onAdd(3)}>+3</button>
      </div>`;
  }

  function Board() {
    const [home, setHome] = useState(0);
    const [away, setAway] = useState(0);
    const lead = home === away ? 'Level'
      : (home > away ? 'Lions' : 'Hawks') + ' lead by ' + Math.abs(home - away);
    return html`
      <div class="board">
        <${Team} name="Lions" color="#2563eb" score=${home} onAdd=${n => setHome(home + n)} />
        <${Team} name="Hawks" color="#c2410c" score=${away} onAdd=${n => setAway(away + n)} />
      </div>
      <p class="lead" id="lead">${lead}</p>
      <button class="reset" onClick=${() => { setHome(0); setAway(0); }}>Reset</button>`;
  }

  render(html`<${Board} />`, document.getElementById('app'));
</script>
</body>
</html>
Team is written once and used twice. Board owns both scores and works out who leads.

Because Board holds both numbers, the "Lions lead by 2" line is just a calculation in the same function. Nothing has to be kept in sync by hand.

A finished example: a quiz night leaderboard

This one adds players with a form, sorts by points, and resets. The form is handled in the page with preventDefault(), so nothing is sent anywhere.

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>Preact leaderboard</title>
<style>
  body { margin: 0; padding: 18px 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #111827; }
  .wrap { max-width: 420px; margin: 0 auto; padding: 16px; border-radius: 14px; background: #fff;
          box-shadow: 0 6px 20px rgba(0, 0, 0, .1); }
  h1 { font-size: 20px; margin: 0 0 12px; }
  form { display: flex; gap: 8px; margin-bottom: 12px; }
  input { flex: 1; min-width: 0; font: inherit; padding: 9px 10px; border: 1px solid #d1d5db; border-radius: 9px; }
  button { font: inherit; padding: 8px 12px; border: 0; border-radius: 9px; background: #2563eb; color: #fff; cursor: pointer; }
  ol { list-style: none; margin: 0; padding: 0; }
  li { display: flex; align-items: center; gap: 8px; padding: 8px 4px; border-bottom: 1px solid #eef0f3; }
  li:first-child .rank { background: #fde68a; }
  .rank { flex: none; width: 28px; height: 28px; border-radius: 50%; background: #f3f4f6;
          display: grid; place-items: center; font-weight: 700; }
  .name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
  .pts { width: 36px; text-align: right; font-weight: 800; font-size: 18px; }
  li button { padding: 6px 10px; }
  li .minus { background: #e5e7eb; color: #111827; }
  .foot { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; color: #6b7280; font-size: 14px; }
  .foot button { background: #e5e7eb; color: #111827; }
</style>
<script type="importmap">
{
  "imports": {
    "preact": "https://cdn.jsdelivr.net/npm/preact@11.0.0/dist/preact.mjs",
    "preact/hooks": "https://cdn.jsdelivr.net/npm/preact@11.0.0/hooks/dist/hooks.mjs",
    "htm": "https://cdn.jsdelivr.net/npm/htm@3.1.1/dist/htm.module.js",
    "htm/preact": "https://cdn.jsdelivr.net/npm/htm@3.1.1/preact/index.module.js"
  }
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module">
  import { render } from 'preact';
  import { useState } from 'preact/hooks';
  import { html } from 'htm/preact';

  let nextId = 4;
  const start = [
    { id: 1, name: 'Ana', pts: 12 },
    { id: 2, name: 'Ben', pts: 9 },
    { id: 3, name: 'Chloe', pts: 15 },
  ];

  function Leaderboard() {
    const [players, setPlayers] = useState(start);
    const [name, setName] = useState('');

    // Always hand the setter a new array. Editing the old one in place does not redraw.
    const add = (id, n) => setPlayers(players.map(p => p.id === id ? { ...p, pts: p.pts + n } : p));

    function onSubmit(e) {
      e.preventDefault();  // handled in the page, nothing is sent anywhere
      if (!name.trim()) return;
      setPlayers([...players, { id: nextId++, name: name.trim(), pts: 0 }]);
      setName('');
    }

    const sorted = [...players].sort((a, b) => b.pts - a.pts);
    return html`
      <div class="wrap">
        <h1>Quiz night scores</h1>
        <form onSubmit=${onSubmit}>
          <input placeholder="Player name" value=${name} onInput=${e => setName(e.currentTarget.value)} />
          <button>Add</button>
        </form>
        <ol>
          ${sorted.map((p, i) => html`
            <li key=${p.id}>
              <span class="rank">${i + 1}</span>
              <span class="name">${p.name}</span>
              <span class="pts">${p.pts}</span>
              <button class="minus" onClick=${() => add(p.id, -1)}>-1</button>
              <button onClick=${() => add(p.id, 1)}>+1</button>
            </li>`)}
        </ol>
        <div class="foot">
          <span>${players.length} players</span>
          <button onClick=${() => setPlayers(players.map(p => ({ ...p, pts: 0 })))}>Reset scores</button>
        </div>
      </div>`;
  }

  render(html`<${Leaderboard} />`, document.getElementById('app'));
</script>
</body>
</html>
Add a player, give points, and watch the order change. Each row has a key so Preact can match it after sorting.

Three details worth copying:

  • key on list rows. The Preact tutorial explains that elements with a key "are compared by finding the previous element with that same key prop value", rather than by position. Use a stable id, not the index.
  • onInput, not onChange. In Preact core, onChange is the DOM change event, which fires when the value is committed. The guide says to use onInput for form inputs, unless preact/compat is used.
  • class works. The guide says most Preact developers use class instead of className, and both are supported.

For a scores page that needs no library at all, see the HTML quiz with a score.

When it does not work

What you see Cause Fix
Failed to resolve module specifier "preact" No import map, or it comes after the module script Put the import map in <head>, above every module script
Cannot use import statement outside a module The script has no type="module" Use <script type="module">
SyntaxError: Unexpected token '<' JSX in a plain script Write the markup with htm
An empty Score element, no content <Score /> inside htm Write <${Score} />
Error mentioning __H, nothing renders Two copies of Preact, such as hooks from a different URL Load preact and preact/hooks from the same version and host
The number only changes when you leave the field onChange on a text input Use onInput
Clicking does nothing after push() The same array was passed to the setter Pass a new array

The error texts above are what Chromium shows in the console. If the page is blank and the console is empty, HTML JavaScript not working lists other causes.

A scoreboard is meant to be used, not looked at. A screenshot cannot count points, so send the page itself.

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, its scripts run, and Preact loads from jsDelivr, so the people you send it to can press the buttons themselves without an account. If you change the code later, the same link shows the new version.

Questions people ask

Can I use Preact without npm or a build tool?

Yes. The Preact guide says it is packaged to be used directly in the browser and does not require any build or tools. Load it with an import statement in a module script, and map the name "preact" to a CDN URL with an import map.

Why does JSX not work in my HTML file?

JSX is not part of JavaScript, so the browser stops at the first < with a SyntaxError. The Preact guide notes that JSX requires a build step and suggests htm, which gives a JSX-like syntax with tagged templates that browsers run as they are.

Which Preact version should I load?

On npm, the latest tag of preact points to 11.0.0, and the Preact guide marks version 11.x as current. Put the exact version in every URL, and load preact and preact/hooks from the same version and the same host.

Is Preact free to use?

Yes. The preact package is published under the MIT licence. htm, the template helper used here, is published under the Apache-2.0 licence.

Why does my score not update when I push to the array?

The useState setter compares the new value with the old one using Object.is. If you push into the same array and pass it back, the value is the same object, so Preact does not draw again. Pass a new array, for example with [...list, item] or list.map().

Keep reading