Alpine.js in a single HTML file

Alpine.js is a small JavaScript framework you load with one script tag. You write attributes such as x-data, @click and x-text in your HTML, and the page reacts without a build step.

Alpine.js is a small JavaScript framework for adding behaviour straight into HTML. Its site calls it "your new, lightweight, JavaScript framework".

You load it with one script tag, add an x-data attribute, and the elements inside can count clicks, show and hide, and follow form inputs.

There is no build step and no npm. Click the buttons below, then type a name.

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>Alpine.js in one HTML file</title>
<!-- Load Alpine with defer, pinned to one version -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"></script>
<style>
  [x-cloak] { display: none !important; }  /* hide until Alpine is ready */
  body { margin: 0; padding: 20px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .box {
    max-width: 380px; padding: 16px 18px; border-radius: 12px;
    background: #fff; box-shadow: 0 4px 14px rgba(0, 0, 0, .1);
  }
  button {
    font: inherit; padding: 8px 14px; border: 0; border-radius: 8px;
    background: #2f7d6d; color: #fff; cursor: pointer;
  }
  input { font: inherit; padding: 7px 9px; border: 1px solid #cfd4dc; border-radius: 8px; width: 100%; box-sizing: border-box; }
  p { margin: 12px 0 0; }
</style>
</head>
<body>

<!-- x-data makes this div a component and holds its state -->
<div class="box" x-data="{ count: 0, open: false, name: '' }">
  <button @click="count++">Clicked <span x-text="count"></span> times</button>
  <button @click="open = !open" x-text="open ? 'Hide' : 'Show'"></button>

  <p x-show="open" x-cloak>
    <input x-model="name" placeholder="Type your name">
    <span x-text="name ? 'Hello, ' + name + '!' : 'Waiting for a name...'"></span>
  </p>
</div>

</body>
</html>
One script tag loads Alpine 3.17.4. Everything else is attributes on plain HTML. Edit the code and the example reruns.

There is no <script> with click handlers in that page. The x-data object holds the state, and the attributes read and change it.

The smallest working page

Every Alpine page has the same three parts:

  1. The script tag, with defer. The Alpine installation page puts it in the <head>. The docs remind you not to forget defer.
  2. An x-data element. It turns that chunk of HTML into a component and holds its data as a JavaScript object.
  3. Directives inside it. @click runs an expression on click, and x-text sets the text from an expression.
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"></script>

<div x-data="{ count: 0 }">
  <button @click="count++">Add one</button>
  <span x-text="count"></span>
</div>

The docs show the link with @3.x.x, which pulls the latest version 3 release. For stability they recommend writing the exact version, as above.

How Alpine updates the page

You do not look up elements or set textContent. You change the data, and every directive that reads it updates.

Data in x-data, an event that changes it, and the HTML that follows.
Data in x-data, an event that changes it, and the HTML that follows.

These are the directives you will use most. Each one takes a plain JavaScript expression.

Directive What it does
x-data Makes the element a component and declares its data
x-on:click or @click Runs an expression when the event fires
x-text Sets the element's text to the result of an expression
x-show Shows or hides the element
x-bind:class or :class Sets an attribute, such as a class, from data
x-model Binds an input's value to data, both ways
x-for Repeats a <template> once per item in a list
x-cloak Hides the element until Alpine has set it up

@ is the docs' shorthand for x-on:, and : is short for x-bind:. Both forms work the same.

A variant: tabs with x-show and :class

Tabs need one piece of state: which tab is open. Each button sets it, each panel shows when it matches, and :class underlines the current button.

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>Tabs with Alpine.js</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"></script>
<style>
  [x-cloak] { display: none !important; }
  body { margin: 0; padding: 20px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .tabs { max-width: 420px; background: #fff; border-radius: 12px; box-shadow: 0 4px 14px rgba(0, 0, 0, .1); overflow: hidden; }
  .bar { display: flex; border-bottom: 1px solid #e3e6eb; }
  .bar button {
    flex: 1; font: inherit; padding: 11px 6px; border: 0; background: none;
    cursor: pointer; color: #555; border-bottom: 3px solid transparent;
  }
  .bar button.on { color: #2f7d6d; border-bottom-color: #2f7d6d; font-weight: 600; }
  .panel { padding: 16px 18px; line-height: 1.5; }
  .panel h3 { margin: 0 0 6px; font-size: 16px; }
  .panel p { margin: 0; }
</style>
</head>
<body>

<div class="tabs" x-data="{ tab: 'plan' }">
  <div class="bar">
    <!-- :class adds "on" when this tab is the current one -->
    <button @click="tab = 'plan'"  :class="{ on: tab === 'plan' }">Plan</button>
    <button @click="tab = 'build'" :class="{ on: tab === 'build' }">Build</button>
    <button @click="tab = 'ship'"  :class="{ on: tab === 'ship' }">Ship</button>
  </div>

  <!-- x-show hides a panel with display: none -->
  <div class="panel" x-show="tab === 'plan'">
    <h3>Plan</h3><p>Write down what the page must do. One sentence per feature.</p>
  </div>
  <div class="panel" x-show="tab === 'build'" x-cloak>
    <h3>Build</h3><p>Add x-data, then one directive at a time. Reload after each.</p>
  </div>
  <div class="panel" x-show="tab === 'ship'" x-cloak>
    <h3>Ship</h3><p>Paste the file into a document and share it as a link.</p>
  </div>
</div>

</body>
</html>
Three buttons and three panels share one tab variable. No JavaScript block at all.
<div x-data="{ tab: 'plan' }">
  <button @click="tab = 'plan'" :class="{ on: tab === 'plan' }">Plan</button>
  <div x-show="tab === 'plan'">...</div>
</div>

x-show hides an element with an inline display: none, so the panels stay in the page. To compare, tabs without JavaScript builds the same thing with radio buttons and CSS.

Directives must sit inside x-data

The most common silent failure: a directive placed outside every x-data element. Alpine does not process it and prints no error. The element keeps whatever text was in the HTML.

Left: the span is after the closing tag, so it never updates. Right: moved inside, it follows the data.
Left: the span is after the closing tag, so it never updates. Right: moved inside, it follows the data.

The x-data docs say properties are available to all child elements, nested components included. So put x-data high enough to wrap everything that needs the data. On a one-file page, <body x-data="..."> is fine.

Moving the data into a script: Alpine.data

When the object in x-data grows, move it into a <script> with Alpine.data(). Then x-data="todos" refers to it by name. The catch is timing.

Alpine.data must run inside the alpine:init event, not at the top level.
Alpine.data must run inside the alpine:init event, not at the top level.

The script tag has defer, so Alpine runs after the page is parsed. An inline script that calls Alpine.data directly runs earlier. In our test, the console showed "Alpine is not defined" and the component stayed empty.

The docs register components inside the alpine:init event instead. Alpine fires it after it has loaded, but before it initializes itself on the page:

document.addEventListener('alpine:init', () => {
  Alpine.data('todos', () => ({
    items: [],
    add() { /* ... */ },
  }));
});

A finished example: a to-do list

This list uses Alpine.data, x-model on the text box and the checkboxes, x-for with a key, and two getters for the filter and the counter.

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>To-do list with Alpine.js</title>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"></script>
<script>
  // Register the component before Alpine starts
  document.addEventListener('alpine:init', () => {
    Alpine.data('todos', () => ({
      text: '',
      filter: 'all',
      nextId: 4,
      items: [
        { id: 1, label: 'Load Alpine with defer', done: true },
        { id: 2, label: 'Add x-data', done: false },
        { id: 3, label: 'Share the page', done: false },
      ],
      add() {
        if (!this.text.trim()) return;
        this.items.push({ id: this.nextId++, label: this.text.trim(), done: false });
        this.text = '';
      },
      remove(id) { this.items = this.items.filter(i => i.id !== id); },
      // Getters recalculate whenever items or filter change
      get shown() {
        if (this.filter === 'open') return this.items.filter(i => !i.done);
        if (this.filter === 'done') return this.items.filter(i => i.done);
        return this.items;
      },
      get left() { return this.items.filter(i => !i.done).length; },
    }));
  });
</script>
<style>
  [x-cloak] { display: none !important; }
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #eceef1; }
  .app { max-width: 440px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 4px 16px rgba(0, 0, 0, .1); }
  h2 { margin: 0 0 12px; font-size: 18px; }
  form { display: flex; gap: 8px; }
  input[type=text] { flex: 1; min-width: 0; font: inherit; padding: 8px 10px; border: 1px solid #cfd4dc; border-radius: 8px; }
  button { font: inherit; padding: 8px 12px; border: 0; border-radius: 8px; background: #2f7d6d; color: #fff; cursor: pointer; }
  ul { list-style: none; padding: 0; margin: 12px 0; }
  li { display: flex; align-items: center; gap: 10px; padding: 8px 4px; border-bottom: 1px solid #eef0f3; }
  li span { flex: 1; }
  li.done span { text-decoration: line-through; color: #999; }
  li button { background: none; color: #b0413e; padding: 2px 8px; }
  .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; color: #666; flex-wrap: wrap; }
  .foot button { background: #eef0f3; color: #333; padding: 5px 10px; }
  .foot button.on { background: #2f7d6d; color: #fff; }
</style>
</head>
<body>

<div class="app" x-data="todos" x-cloak>
  <h2>To-do list</h2>

  <!-- .prevent stops the form from reloading the page -->
  <form @submit.prevent="add()">
    <input type="text" x-model="text" placeholder="New task" aria-label="New task">
    <button>Add</button>
  </form>

  <ul>
    <!-- x-for goes on a template with one root element; :key keeps rows stable -->
    <template x-for="item in shown" :key="item.id">
      <li :class="{ done: item.done }">
        <input type="checkbox" x-model="item.done" :aria-label="item.label">
        <span x-text="item.label"></span>
        <button @click="remove(item.id)" aria-label="Delete">✕</button>
      </li>
    </template>
  </ul>

  <div class="foot">
    <span x-text="left + (left === 1 ? ' task' : ' tasks') + ' left'"></span>
    <div>
      <button @click="filter = 'all'"  :class="{ on: filter === 'all' }">All</button>
      <button @click="filter = 'open'" :class="{ on: filter === 'open' }">Open</button>
      <button @click="filter = 'done'" :class="{ on: filter === 'done' }">Done</button>
    </div>
  </div>
</div>

</body>
</html>
Add with Enter, tick items off, filter, delete. The whole app is one HTML file.
  • @submit.prevent calls add() and stops the form from reloading the page.
  • x-for must sit on a <template> with one root element. :key="item.id" lets Alpine keep the right rows when the list changes.
  • Getters such as get left() recalculate from the current data. The docs note they are not cached.
  • x-cloak on the app hides the raw template until Alpine has rendered it.

For the same app in plain JavaScript, see a simple HTML to-do list.

When it does not work

What you see Cause Fix
Nothing reacts, no error in the console The elements are outside every x-data Move x-data up to a wrapping element
Console: "Unable to initialize ... Did you forget to add defer" The script tag in <head> has no defer Add defer
Console: "Alpine is not defined" Alpine.data runs before Alpine loads Wrap it in an alpine:init listener
Hidden content flashes on load x-show only applies once Alpine runs Add x-cloak and the [x-cloak] CSS rule
x-for shows nothing and logs an error It is on an <li>, not a <template> Put it on a <template> around the <li>
Only the first element of each x-for row appears The <template> has two root elements Wrap them in one element
The input's value attribute is ignored x-model sets the input from the data Put the starting value in x-data
An x-show element will not hide A CSS rule sets display with !important Use x-show.important

For problems that are not Alpine-specific, such as a wrong script URL, see HTML JavaScript not working.

An Alpine page is easier to show than to describe. A screenshot cannot be clicked, and an .html attachment 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, Alpine loads from jsDelivr and its scripts run, so the people you send it to can click, type and tick items themselves, without an account. If you change the code later, the same link shows the new version.

Keep the data in the page. Forms handled inside the page work, as the to-do list does, but sending a form to a server and fetching from other sites are blocked. For more pages built from one file, see single HTML file apps.

Questions people ask

Do I need npm or a build step to use Alpine.js?

No. The Alpine docs list two ways to install it: a script tag from a CDN, or the alpinejs package from npm imported into a bundle. The script tag needs nothing else, so one HTML file is enough.

Which Alpine.js version should I load?

The installation page of the Alpine docs pins 3.17.4 in its CDN link, and 3.17.4 is the latest tag on npm. The docs recommend hardcoding the version in the link for stability in production, instead of 3.x.x.

Is Alpine.js free?

Yes. Alpine.js is released under the MIT licence, which grants permission to use, copy, modify and distribute it free of charge.

Why do I see my hidden content flash before the page settles?

The browser shows the HTML before Alpine has loaded and applied x-show. Add x-cloak to the element and the CSS rule [x-cloak] { display: none !important; }. Alpine removes the attribute once it has set the element up.

Does Alpine.js work on a page with a strict Content Security Policy?

The standard build evaluates attribute expressions with Function declarations, which a policy without unsafe-eval blocks. The Alpine docs offer a separate CSP build, @alpinejs/csp, that supports most of the inline expression syntax.

Keep reading