Tag: Alpine.js

Lightweight Form Validation with Alpine.js and Iodine.js

Many users these days expect instant feedback in form validation. How do you achieve this level of interactivity when you’re building a small static site or a server-rendered Rails or Laravel app? Alpine.js and Iodine.js are two minimal JavaScript libraries we can use to create highly interactive forms with little technical debt and a negligible hit to our page-load time. Libraries like these prevent you from having to pull in build-step heavy JavaScript tooling which can complicate your architecture.

I‘m going to iterate through a few versions of form validation to explain the APIs of these two libraries. If you want to copy and paste the finished product here‘s what we’re going to build. Try playing around with missing or invalid inputs and see how the form reacts:

A quick look at the libraries

Before we really dig in, it’s a good idea to get acquainted with the tooling we’re using.

Alpine is designed to be pulled into your project from a CDN. No build step, no bundler config, and no dependencies. It only needs a short GitHub README for its documentation. At only 8.36 kilobytes minfied and gzipped, it’s about a fifth of the size of a create-react-app hello world. Hugo Di Fracesco offers a complete and thorough overview of what it is an how it works. His initial description of it is pretty great:

Alpine.js is a Vue template-flavored replacement for jQuery and vanilla JavaScript rather than a React/Vue/Svelte/WhateverFramework competitor.

Iodine, on the other hand, is a micro form validation library, created by Matt Kingshott who works in the Laravel/Vue/Tailwind world. Iodine can be used with any front-end-framework as a form validation helper. It allows us to validate a single piece of data with multiple rules. Iodine also returns sensible error messages when validation fails. You can read more in Matt’s blog post explaining the reasoning behind Iodine.

A quick look at how Iodine works

Here’s a very basic client side form validation using Iodine. We‘ll write some vanilla JavaScript to listen for when the form is submitted, then use DOM methods to map through the inputs to check each of the input values. If it‘s incorrect, we’ll add an “invalid” class to the invalid inputs and prevent the form from submitting.

We’ll pull in Iodine from this CDN link for this example:

<script src="https://cdn.jsdelivr.net/gh/mattkingshott/iodine@3/dist/iodine.min.js" defer></script>

Or we can import it into a project with Skypack:

import kingshottIodine from "https://cdn.skypack.dev/@kingshott/iodine";

We need to import kingshottIodine when importing Iodine from Skypack. This still adds Iodine to our global/window scope. In your user code, you can continue to refer to the library as Iodine, but make sure to import kingshottIodine if you’re grabbing it from Skypack.

To check each input, we call the is method on Iodine. We pass the value of the input as the first parameter, and an array of strings as the second parameter. These strings are the rules the input needs to follow to be valid. A list of built-in rules can be found in the Iodine documentation.

Iodine’s is method either returns true if the value is valid, or a string that indicates the failed rule if the check fails. This means we‘ll need to use a strict comparison when reacting to the output of the function; otherwise, JavaScript assesses the string as true. What we can do is store an array of strings for the rules for each input as JSON in HTML data attributes. This isn’t built into either Alpine or Iodine, but I find it a nice way to co-locate inputs with their constraints. Note that if you do this you’ll need to surround the JSON with single quotes and use double quotes inside the attribute to follow the JSON spec.

Here’s how this looks in our HTML:

<input name="email" type="email" id="email" data-rules='["required","email"]'>

When we‘re mapping through the DOM to check the validity of each input, we call the Iodine function with the element‘s input value, then the JSON.encode() result of the input’s dataset.rules. This is what this looks like using vanilla JavaScript DOM methods:

let form = document.getElementById("form");  // This is a nice way of getting a list of checkable input elements // And converting them into an array so we can use map/filter/reduce functions: let inputs = [...form.querySelectorAll("input[data-rules]")];  function onSubmit(event) {   inputs.map((input) => {     if (Iodine.is(input.value, JSON.parse(input.dataset.rules)) !== true) {       event.preventDefault();       input.classList.add("invalid");     }   }); } form.addEventListener("submit", onSubmit);

Here’s what this very basic implementation looks like:

As you can tell this is not a great user experience. Most importantly, we aren’t telling the user what is wrong with the submission. The user also has to wait until the form is submitted before finding out anything is wrong. And frustratingly, all of the inputs keep the “invalid” class even after the user has corrected them to follow our validation rules.

This is where Alpine comes into play

Let’s pull it in and use it to provide nice user feedback while interacting with the form.

A good option for form validation is to validate an input when it’s blurred or on any changes after it has been blurred. This makes sure we‘re not yelling at the user before they’ve finished writing, but still give them instant feedback if they leave an invalid input or go back and correct an input value.

We’ll pull Alpine in from the CDN:

<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.7.3/dist/alpine.min.js" defer></script>

Or we can import it into a project with Skypack:

import alpinejs from "https://cdn.skypack.dev/alpinejs";

Now there’s only two pieces of state we need to hold for each input:

  • Whether the input has been blurred
  • The error message (the absence of this will mean we have a valid input)

The validation that we show in the form is going to be a function of these two pieces of state.

Alpine lets us hold this state in a component by declaring a plain JavaScript object in an x-data attribute on a parent element. This state can be accessed and mutated by its children elements to create interactivity. To keep our HTML clean, we can declare a JavaScript function that returns all the data and/or functions the form would need. Alpine will look for the this function in the global/window scope of our JavaScript code if we add this function to the x-data attribute. This also provides a reusable way to share logic as we can use the same function in multiple components or even multiple projects.

Let’s initialize the form data to hold objects for each input field with two properties: an empty string for the errorMessage and a boolean called blurred. We’ll use the name attribute of each element as their keys.

 <form id="form" x-data="form()" action="">   <h1>Log In</h1>    <label for="username">Username</label>   <input name="username" id="username" type="text" data-rules='["required"]'>    <label for="email">Email</label>   <input name="email" type="email" id="email" data-rules='["required","email"]'>    <label for="password">Password</label>   <input name="password" type="password" id="password" data-rules='["required","minimum:8"]'>    <label for="passwordConf">Confirm Password</label>   <input name="passwordConf" type="password" id="passwordConf" data-rules='["required","minimum:8"]'>    <input type="submit"> </form>

And here’s our function to set up the data. Note that the keys match the name attribute of our inputs:

window.form = () => {    return {     username: {errorMessage:'', blurred:false},     email: {errorMessage:'', blurred:false},     password: {errorMessage:'', blurred:false},     passwordConf: {errorMessage:'', blurred:false},   } }

Now we can use Alpine’s x-bind:class attribute on our inputs to add the “invalid” class if the input has blurred and a message exists for the element in our component data. Here’s how this looks in our username input:

<input name="username" id="username" type="text"  x-bind:class="{'invalid':username.errorMessage && username.blurred}" data-rules='["required"]'>

Responding to input changes

Now we need our form to respond to input changes and on blurring input states. We can do this by adding event listeners. Alpine gives a concise API to do this either using x-on or, similar to Vue, we can use an @ symbol. Both ways of declaring these act the same way.

On the input event we need to change the errorMessage in the component data to an error message if the value is invalid; otherwise, we’ll make it an empty string.

On the blur event we need to set the blurred property as true on the object with a key matching the name of the blurred element. We also need to recalculate the error message to make sure it doesn’t use the blank string we initialized as the error message.

So we’re going to add two more functions to our form to react to blurring and input changes, and use the name value of the event target to find what part of our component data to change. We can declare these functions as properties in the object returned by the form() function.

Here’s our HTML for the username input with the event listeners attached:

<input    name="username" id="username" type="text"   x-bind:class="{'invalid':username.errorMessage && username.blurred}"    @blur="blur" @input="input"   data-rules='["required"]' >

And our JavaScript with the functions responding to the event listeners:

window.form = () => {   return {     username: {errorMessage:'', blurred:false},     email: {errorMessage:'', blurred:false},     password:{ errorMessage:'', blurred:false},     passwordConf: {errorMessage:'', blurred:false},     blur: function(event) {       let ele = event.target;       this[ele.name].blurred = true;       let rules = JSON.parse(ele.dataset.rules)       this[ele.name].errorMessage = this.getErrorMessage(ele.value, rules);     },     input: function(event) {       let ele = event.target;       let rules = JSON.parse(ele.dataset.rules)       this[ele.name].errorMessage = this.getErrorMessage(ele.value, rules);     },     getErrorMessage: function() {     // to be completed     }   } }

Getting and showing errors

Next up, we need to write our getErrorMessage function.

If the Iodine check returns true, we‘ll set the errorMessage property to an empty string. Otherwise, we’ll pass the rule that has broken to another Iodine method: getErrorMessage. This will return a human-readable message. Here’s what this looks like:

getErrorMessage:function(value, rules){   let isValid = Iodine.is(value, rules);   if (isValid !== true) {     return Iodine.getErrorMessage(isValid);   }   return ''; }

Now we also need to show our error messages to the user.

Let’s add <p> tags with an error-message class below each input. We can use another Alpine attribute called x-show on these elements to only show them when their error message exists. The x-show attribute causes Alpine to toggle display: none; on the element based on whether a JavaScript expression resolves to true. We can use the same expression we used in the the show-invalid class on the input.

To display the text, we can connect our error message with x-text. This will automatically bind the innertext to a JavaScript expression where we can use our component state. Here’s what this looks like:

<p x-show="username.errorMessage && username.blurred" x-text="username.errorMessage" class="error-message"></p>

One last thing we can do is re-use the onsubmit code from before we pulled in Alpine, but this time we can add the event listener to the form element with @submit and use a submit function in our component data. Alpine lets us use $ el to refer to the parent element holding our component state. This means we don’t have to write lengthier DOM methods:

<form id="form" x-data="form()" @submit="submit" action="">   <!-- inputs...  --> </form>
submit: function (event) {   let inputs = [...this.$  el.querySelectorAll("input[data-rules]")];   inputs.map((input) => {     if (Iodine.is(input.value, JSON.parse(input.dataset.rules)) !== true) {       event.preventDefault();     }   }); }

This is getting there:

  • We have real-time feedback when the input is corrected.
  • Our form tells the user about any issues before they submit the form, and only after they’ve blurred the inputs.
  • Our form does not submit when there are invalid properties.

Validating on the client side of a server-side rendered app

There are still some problems with this version, though some won‘t be immediately obvious in the Pen as they‘re related to the server. For example, it‘s difficult to validate all errors on the client side in a server-side rendered app. What if the email address is already in use? Or a complicated database record needs to be checked? Our form needs to have a way to show errors found on the server. There are ways to do this with AJAX, but we’ll look at a more lightweight solution.

We can store the server side errors in another JSON array data attribute on each input. Most back-end frameworks will provide a reasonably easy way to do this. We can use another Alpine attribute called x-init to run a function when the component initializes. In this function we can pull the server-side errors from the DOM into each input’s component data. Then we can update the getErrorMessage function to check whether there are server errors and return these first. If none exist, then we can check for client-side errors.

<input name="username" id="username" type="text"  x-bind:class="{'invalid':username.errorMessage && username.blurred}"  @blur="blur" @input="input" data-rules='["required"]'  data-server-errors='["Username already in use"]'>

And to make sure the server side errors don’t show the whole time, even after the user starts correcting them, we’ll replace them with an empty array whenever their input gets changed.

Here’s what our init function looks like now:

init: function () {   this.inputElements = [...this.$  el.querySelectorAll("input[data-rules]")];   this.initDomData(); }, initDomData: function () {   this.inputElements.map((ele) => {   this[ele.name] = {     serverErrors: JSON.parse(ele.dataset.serverErrors),     blurred: false     };   }); }

Handling interdependent inputs

Some of the form inputs may depend on others for their validity. For example, a password confirmation input would depend on the password it is confirming. Or a date you started a job field would need to hold a value later than your date-of-birth field. This means it’s a good idea to check all the inputs of the form every time an input gets changed.

We can map through all of the input elements and set their state on every input and blur event. This way, we know that inputs that rely on each other will not be using stale data.

To test this out, let’s add a matchingPassword rule for our password confirmation. Iodine lets us add new custom rules with an addRule method.

Iodine.addRule(   "matchingPassword",   value => value === document.getElementById("password").value );

Now we can set a custom error message by adding a key to the messages property in Iodine:

Iodine.messages.matchingPassword="Password confirmation needs to match password";

We can add both of these calls in our init function to set up this rule.

In our previous implementation, we could have changed the “password” field and it wouldn’t have made the “password confirmation” field invalid. But now that we’re mapping through all the inputs on every change, our form will always make sure the password and the password confirmation match.

Some finishing touches

One little refactor we can do is to make the getErrorMessage function only return a message if the input has been blurred — this can make out HTML slightly shorter by only needing to check one value before deciding whether to invalidate an input. This means our x-bind attribute can be as short as this:


Here’s what our functions look like to map through the inputs and set the errorMessage data now:

updateErrorMessages: function () {   // Map through the input elements and set the 'errorMessage'   this.inputElements.map((ele) => {     this[ele.name].errorMessage = this.getErrorMessage(ele);   }); }, getErrorMessage: function (ele) {   // Return any server errors if they're present   if (this[ele.name].serverErrors.length > 0) {     return input.serverErrors[0];   }   // Check using Iodine and return the error message only if the element has not been blurred   const error = Iodine.is(ele.value, JSON.parse(ele.dataset.rules));   if (error !== true && this[ele.name].blurred) {     return Iodine.getErrorMessage(error);   }   // Return empty string if there are no errors   return ""; },

We can also remove the @blur and @input events from all of our inputs by listening for these events in the parent form element. However, there is a problem with this: the blur event does not bubble (parent elements listening for this event will not be passed it when it fires on their children). Luckily, we can replace blur with the focusout event, which is basically the same event, but this one bubbles, so we can listen for it in our form parent element.

Finally, our code is growing a lot of boilerplate. If we were to change any input names we would have to rewrite the data in our function every time and add new event listeners. To prevent rewriting the component data every time, we can map through the form’s inputs that have a data-rules attribute to generate our initial component data in the init function. This makes the code more reusable for additional forms. All we’d need to do is include the JavaScript and add the rules as a data attribute and we’re good to go.

Oh, and hey, just because it’s so easy to do with Alpine, let’s add a fade-in transition that brings attention to the error messaging:

<p class="error-message" x-show.transition.in="username.errorMessage" x-text="username.errorMessage"></p>

And here’s the end result. Reactive, reusable form validation at a minimal page-load cost.

If you want to use this in your own application, you can copy the form function to reuse all the logic we’ve written. All you’d need to do is configure your HTML attributes and you’d be ready to go.

The post Lightweight Form Validation with Alpine.js and Iodine.js appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.


, , , ,

An Eleventy Starter with Tailwind CSS and Alpine.js

When I decided to try to base my current personal website on Eleventy, I didn’t want to reinvent the wheel: I tested all the Eleventy starters built with Tailwind CSS that I could find in Starter Projects from the documentation.

Many of the starters seemed to integrate Tailwind CSS in a contrived way. Also, some of them seemed to assume that no one updates Tailwind’s configuration on the fly while working on a website. That’s why I integrated Eleventy with Tailwind CSS and Alpine.js myself. I have reason to believe that you’ll like the simplicity of my solution.

Good design is as little design as possible.

—Dieter Rams, 10 Principles for Good Design

If you’re uninterested in the details, feel free to grab my starter and jump right in.

Getting started

I’m going to assume you have a general understanding of Tailwind CSS, HTML, JavaScript, Nunjucks, the command line, and npm.

Let’s start by with a new a folder, then cd to it in the command line, and initialize it with a package.json file:

npm init -y

Now we can install Eleventy and Tailwind CSS. We’ll throw in PostCSS as well:

npm install --save-dev @11ty/eleventy tailwindcss postcss-cli

We need to create a page to test whether we’ve successfully set things up. In a real use case, our pages will use templates, so we’ll do that here as well. That’s where Nunjucks fits into the mix, serving as a templating engine.

Let’s make a new file called index.njk in the project folder. We’ll designate it as the homepage:

{% extends "_includes/default.njk" %} 
 {% block title %}It does work{% endblock %} 
 {% block content %}   <div class="fixed inset-0 flex justify-center items-center">     <div>       <span class="text-change">Good design</span><br/>       <span class="change">is<br/>as little design<br/>as possible</span>     </div>   </div> {% endblock %}

Basic templating

Now let’s create a new folder in the project folder called _includes (and yes, the folder name matters). Inside this new folder, we’ll create a file called default.njk that we’ll use as the default template for our layout. We’ll keep things simple with a basic HTML boilerplate:

<!DOCTYPE html> <html lang="en">   <head>     <title>       {% block title %}Does it work?{% endblock %}     </title>     <meta charset="UTF-8"/>     {% if description %}       <meta name="description" content="{{description}}"/>     {% endif %}     <meta http-equiv="x-ua-compatible" content="ie=edge"/>     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"/>     <link rel="stylesheet" href="/style.css?v={% version %}"/>     {% block head %}{% endblock %}   </head>   <body>     {% block content %}       {{ content | safe }}     {% endblock %}   </body> </html>

Configuring Tailwind CSS

Let’s take care of a test for Tailwind CSS in as few moves as possible. First, create a new subfolder called styles and a file in it called tailwind.config.js:

module.exports = {   theme: {     colors: {       change: "transparent"     }   },   variants: {},   plugins: [], }

Then, create a file called tailwind.css in that same styles folder:

/* purgecss start ignore */ @tailwind base; /* purgecss end ignore */ 
 .change {   color: transparent; } 
 /* purgecss start ignore */ @tailwind components; /* purgecss end ignore */ 
 @tailwind utilities;

We’re done with the styles folder for now. What we do need is a configuration file that tells PostCSS to use Tailwind CSS, which we can get by creating a new file in the root directory of the project folder called postcss.config.js. Here’s how we require Tailwind CSS and its configuration file with PostCSS:

module.exports = {   plugins: [     require(`tailwindcss`)(`./styles/tailwind.config.js`)   ], };

Starting and building the project

Now let’s create another new file in the same root directory called .gitignore. This will allow us to define what files to skip when committing the project to a repo, like on GitHub:

_site/ _tmp/ .DS_Store node_modules/ package-lock.json

Next, is another new file, this time one that tells Eleventy what it can ignore, called .eleventyignore. It only needs one line:


OK, now we will create a file called .eleventy.js (note the leading dot!) that basically configures Eleventy, telling it what files to watch and where to save its work:

module.exports = function (eleventyConfig) {   eleventyConfig.setUseGitIgnore(false); 
   eleventyConfig.addPassthroughCopy({ "./_tmp/style.css": "./style.css" }); 
   eleventyConfig.addShortcode("version", function () {     return String(Date.now());   }); };

We can now update the package.json file with all of the scripts we need to start and build the site during development. The dependencies should already be there from the initial setup.

{   "scripts": {     "start": "eleventy --serve & postcss styles/tailwind.css --o _tmp/style.css --watch",     "build": "ELEVENTY_PRODUCTION=true eleventy & ELEVENTY_PRODUCTION=true postcss styles/tailwind.css --o _site/style.css"   },   "devDependencies": {     "@11ty/eleventy": "^0.11.0",     "postcss-cli": "^7.1.0",     "tailwindcss": "^1.4.6"   } }

Hey, great job! We made it. Build the project to generate the initial CSS — this step is only required the very first time we set up. From the command line:

npm run build

And — drumroll, please — let’s officially start the site:

npm run start

Open the page http://localhost:8080 in your browser. It’s not gonna look like much, but check out the page title in the browser tab:

It does work!

We can still do a little more checking to make sure everything’s good. Open up /styles/tailwind.config.js and change the transparent color value to something else, say black. Tailwind’s configuration should reload, along with the page in your browser.

Don’t lose sight of your browser and edit /styles/tailwind.css by changing transparent to black again. Your CSS file should reload and refresh in your browser.

Now we can work nicely with Eleventy and Tailwind CSS!

Optimizing the output

At this point, Tailwind CSS works with Eleventy, but the CSS file is huge. The generated HTML isn’t perfect either because it contains stuff like redundant newline characters. Let’s clean it up:

npm install --save-dev @fullhuman/postcss-purgecss postcss-clean html-minifier

Open up postcss.config.js and replace what’s in there with this:

module.exports = {   plugins: [     require(`tailwindcss`)(`./styles/tailwind.config.js`),     require(`autoprefixer`),     ...(process.env.ELEVENTY_PRODUCTION       ? [           require(`postcss-clean`),           require(`@fullhuman/postcss-purgecss`)({             content: ["_site/**/*.html"],             defaultExtractor: (content) =>               content.match(/[w-/:]+(?<!:)/g) || [],             whitelist: [],             whitelistPatterns: [/body/, /headroom/, /ril/],           }),         ]       : []),   ], };

In the future, in the process of creating your website, if something looks wrong after you build the project and you have the impression that fragments of CSS are missing, add “ignored” class names to the whitelist in the file above.

Add the following line to the beginning of the .eleventy.js file:

const htmlmin = require("html-minifier");

We also need to configure htmlmin in .eleventy.js as well:

eleventyConfig.addTransform("htmlmin", function (content, outputPath) {     if (       process.env.ELEVENTY_PRODUCTION &&       outputPath &&       outputPath.endsWith(".html")     ) {       let minified = htmlmin.minify(content, {         useShortDoctype: true,         removeComments: true,         collapseWhitespace: true,       });       return minified;     } 
     return content; });

We’re using a transform here which is an Eleventy thing. Transforms can modify a template’s output. At this point, .eleventy.js should look like this:

const htmlmin = require("html-minifier"); 
 module.exports = function (eleventyConfig) {   eleventyConfig.setUseGitIgnore(false); 
   eleventyConfig.addPassthroughCopy({ "./_tmp/style.css": "./style.css" }); 
   eleventyConfig.addShortcode("version", function () {     return String(Date.now());   }); 
   eleventyConfig.addTransform("htmlmin", function (content, outputPath) {     if (       process.env.ELEVENTY_PRODUCTION &&       outputPath &&       outputPath.endsWith(".html")     ) {       let minified = htmlmin.minify(content, {         useShortDoctype: true,         removeComments: true,         collapseWhitespace: true,       });       return minified;     } 
     return content;   }); };

Alright, let’s run npm run start once again. You’ll see that nothing has changed and that’s because optimization only happens during build. So, instead, let’s try npm run build and then look at the _site folder. There shouldn’t be a single unnecessary character in the index.html file. The same goes for the style.css file.

A project built like this is now ready to deploy. Good job! 🏆

Integrating Alpine.js

I decided to switch to Eleventy from Gatsby.js because it just felt like too much JavaScript to me. I’m more into the reasonable dose of vanilla JavaScript mixed with Alpine.js. We won’t get into the specifics of Alpine.js here, but it’s worth checking out Hugo DiFrancesco’s primer because it’s a perfect starting point.

Here’s how we can install it to our project from the command line:

npm install --save-dev alpinejs

Now we need to update .eleventy.js with this to the function that passes things through Alpine.js:

eleventyConfig.addPassthroughCopy({   "./node_modules/alpinejs/dist/alpine.js": "./js/alpine.js", }); 

Lastly, we’ll open up _includes/default.njk and add Alpine.js right before the closing </head> tag:

<script src="/js/alpine.js?v={% version %}"></script>

We can check if Alpine is working by adding this to index.njk:

{% extends "_includes/default.njk" %} 
 {% block title %}It does work{% endblock %} 
 {% block content %}   <div class="fixed inset-0 flex justify-center items-center">     <div>       <span class="text-change">Good design</span><br/>       <span class="change">is<br/>as little design<br/>as possible</span><br/>       <span x-data="{message:'🤖 Hello World 🤓'}" x-text="message"></span>     </div>   </div> {% endblock %}

Launch the project:

npm run start

If Alpine.js works, you’ll see “Hello World” in your browser. Congratulations, times two! 🏆🏆

I hope you can see how quick it can be to set up an Eleventy project, including integrations with Nunjucks for templating, Tailwind for styles and Alpine.js for scripts. I know working with new tech can be overwhelming and even confusing, so feel free to email me at csstricks@gregwolanski.com if you have problems starting up or have an idea for how to simplify this even further.

The post An Eleventy Starter with Tailwind CSS and Alpine.js appeared first on CSS-Tricks.


, , ,

Alpine.js: The JavaScript Framework That’s Used Like jQuery, Written Like Vue, and Inspired by TailwindCSS

We have big JavaScript frameworks that tons of people already use and like, including React, Vue, Angular, and Svelte. Do we need another JavaScript library? Let’s take a look at Alpine.js and you can decide for yourself. Alpine.js is for developers who aren’t looking to build a single page application (SPA). It’s lightweight (~7kB gzipped) and designed to write markup-driven client-side JavaScript.

The syntax is borrowed from Vue and Angular directive. That means it will feel familiar if you’ve worked with those before. But, again, Alpine.js is not designed to build SPAs, but rather enhance your templates with a little bit of JavaScript.

For example, here’s an Alpine.js demo of an interactive “alert” component.

The alert message is two-way bound to the input using x-model="msg". The “level” of the alert message is set using a reactive level property. The alert displays when when both msg and level have a value.

It’s like a replacement for jQuery and JavaScript, but with declarative rendering

Alpine.js is a Vue template-flavored replacement for jQuery and vanilla JavaScript rather than a React/Vue/Svelte/WhateverFramework competitor.

Since Alpine.js is less than a year old, it can make assumptions about DOM APIs that jQuery cannot. Let’s briefly draw a comparison between the two.

Querying vs. binding

The bulk of jQuery’s size and features comes in the shape of a cross-browser compatibility layer over imperative DOM APIs — this is usually referred to as jQuery Core and sports features that can query the DOM and manipulate it.

The Alpine.js answer to jQuery core is a declarative way to bind data to the DOM using the x-bind attribute binding directive. It can be used to bind any attribute to reactive data on the Alpine.js component. Alpine.js, like its declarative view library contemporaries (React, Vue), provides x-ref as an escape hatch to directly access DOM elements from JavaScript component code when binding is not sufficient (eg. when integrating a third-party library that needs to be passed a DOM Node).

Handling events

jQuery also provides a way to handle, create and trigger events. Alpine.js provides the x-on directive and the $ event magic value which allows JavaScript functions to handle events. To trigger (custom) events, Alpine.js provides the $ dispatch magic property which is a thin wrapper over the browser’s Event and Dispatch Event APIs.


One of jQuery’s key features is its effects, or rather, it’s ability to write easy animations. Where we might use slideUp, slideDown, fadeIn, fadeOut properties in jQuery to create effects, Alpine.js provides a set of x-transition directives, which add and remove classes throughout the element’s transition. That’s largely inspired by the Vue Transition API.

Also, jQuery’s Ajax client has no prescriptive solution in Alpine.js, thanks to the Fetch API or taking advantage of a third party HTTP library (e.g. axios, ky, superagent).


It’s also worth calling out jQuery plugins. There is no comparison to that (yet) in the Alpine.js ecosystem. Sharing Alpine.js components is relatively simple, usually requiring a simple case of copy and paste. The JavaScript in Alpine.js components are “just functions” and tend not to access Alpine.js itself, making them relatively straightforward to share by including them on different pages with a script tag. Any magic properties are added when Alpine initializes or is passed into bindings, like $ event in x-on bindings.

There are currently no examples of Alpine.js extensions, although there are a few issues and pull requests to add “core” events that hook into Alpine.js from other libraries. There are also discussions happening about the ability to add custom directives. The stance from Alpine.js creator Caleb Porzio, seems to be basing API decisions on the Vue APIs, so I would expect that any future extension point would be inspired on what Vue.js provides.


Alpine.js is lighter weight than jQuery, coming in at 21.9kB minified — 7.1kB gzipped — compared to jQuery at 87.6kB minified — 30.4kB minified and gzipped. Only 23% the size!

Most of that is likely due to the way Alpine.js focuses on providing a declarative API for the DOM (e.g. attribute binding, event listeners and transitions).

Bundlephobia breaks down the two

For the sake of comparison, Vue comes in at 63.5kB minified (22.8kB gzipped). How can Alpine.js come in lighter despite it’s API being equivalent Vue? Alpine.js does not implement a Virtual DOM. Instead, it directly mutates the DOM while exposing the same declarative API as Vue.

Let’s look at an example

Alpine is compact because since application code is declarative in nature, and is declared via templates. For example, here’s a Pokemon search page using Alpine.js:

This example shows how a component is set up using x-data and a function that returns the initial component data, methods, and x-init to run that function on load.

Bindings and event listeners in Alpine.js with a syntax that’s strikingly similar to Vue templates.

  • Alpine: x-bind:attribute="express" and x-on:eventName="expression", shorthand is :attribute="expression" and @eventName="expression" respectively
  • Vue: v-bind:attribute="express" and v-on:eventName="expression", shorthand is :attribute="expression" and @eventName="expression" respectively

Rendering lists is achieved with x-for on a template element and conditional rendering with x-if on a template element.

Notice that Alpine.js doesn’t provide a full templating language, so there’s no interpolation syntax (e.g. {{ myValue }} in Vue.js, Handlebars and AngularJS). Instead, binding dynamic content is done with the x-text and x-html directives (which map directly to underlying calls to Node.innerText and Node.innerHTML).

An equivalent example using jQuery is an exercise you’re welcome to take on, but the classic style includes several steps:

  • Imperatively bind to the button click using $ ('button').click(/* callback */).
  • Within this “click callback” get the input value from the DOM, then use it to call the API.
  • Once the call has completed, the DOM is updated with new nodes generated from the API response.

If you’re interested in a side by side comparison of the same code in jQuery and Alpine.js, Alex Justesen created the same character counter in jQuery and in Alpine.js.

Back in vogue: HTML-centric tools

Alpine.js takes inspiration from TailwindCSS. The Alpine.js introduction on the repository is as “Tailwind for JavaScript.”

Why is that important?

One of Tailwind’s selling points is that it “provides low-level utility classes that let you build completely custom designs without ever leaving your HTML.” That’s exactly what Alpine does. It works inside HTML so there is no need to work inside of JavaScript templates the way we would in Vue or React  Many of the Alpine examples cited in the community don’t even use script tags at all!

Let’s look at one more example to drive the difference home. Here’s is an accessible navigation menu in Alpine.js that uses no script tags whatsoever.

This example leverages aria-labelledby and aria-controls outside of Alpine.js (with id references). Alpine.js makes sure the “toggle” element (which is a button), has an aria-expanded attribute that’s true when the navigation is expanded, and false when it’s collapsed. This aria-expanded binding is also applied to the menu itself and we show/hide the list of links in it by binding to hidden.

Being markup-centric means that Alpine.js and TailwindCSS examples are easy to share. All it takes is a copy-paste into HTML that is also running Alpine.js/TailwindCSS. No crazy directories full of templates that compile and render into HTML!

Since HTML is a fundamental building block of the web, it means that Alpine.js is ideal for augmenting server-rendered (Laravel, Rails, Django) or static sites (Hugo, Hexo, Jekyll). Integrating data with this sort of tooling can be a simple as outputting some JSON into the x-data="{}" binding. The affordance of passing some JSON from your backend/static site template straight into the Alpine.js component avoids building “yet another API endpoint” that simply serves a snippet of data required by a JavaScript widget.

Client-side without the build step

Alpine.js is designed to be used as a direct script include from a public CDN. Its developer experience is tailored for that. That’s why it makes for a great jQuery comparison and replacement: it’s dropped in and eliminates a build step.

While it’s not traditionally used this way, the bundled version of Vue can be linked up directly. Sarah Drasner has an excellent write-up showing examples of jQuery substituted with Vue. However, if you use Vue without a build step, you’re actively opting out of:

  • the Vue CLI
  • single file components
  • smaller/more optimized bundles
  • a strict CSP (Content Security Policy) since Vue inline templates evaluate expressions client-side

So, yes, while Vue boasts a buildless implementation, its developer experience is really depedent on the Vue CLI. That could be said about Create React App for React, and the Angular CLI. Going build-less strips those frameworks of their best qualities.

There you have it! Alpine.js is a modern, CDN-first  library that brings declarative rendering for a small payload — all without the build step and templates that other frameworks require. The result is an HTML-centric approach that not only resembles a modern-day jQuery but is a great substitute for it as well.

If you’re looking for a jQuery replacement that’s not going to force you into a SPAs architecture, then give Alpine.js a go! Interested? You can find out more on Alpine.js Weekly, a free weekly roundup of Alpine.js news and articles.

The post Alpine.js: The JavaScript Framework That’s Used Like jQuery, Written Like Vue, and Inspired by TailwindCSS appeared first on CSS-Tricks.


, , , , , , , , ,