How To Build A Progressively Enhanced, Accessible, Filterable And Paginated List

About The Author

Manuel Matuzović is a frontend developer from Vienna who’s passionate about accessibility, progressive enhancement, performance, and web standards. He’s one of … More about Manuel ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

Ever wondered how to build a paginated list that works with and without JavaScript? In this article, Manuel explains how you can leverage the power of Progressive Enhancement and do just that with Eleventy and Alpine.js.

Most sites I build are static sites with HTML files generated by a static site generator or pages served on a server by a CMS like Wordpress or CraftCMS. I use JavaScript only on top to enhance the user experience. I use it for things like disclosure widgets, accordions, fly-out navigations, or modals.

The requirements for most of these features are simple, so using a library or framework would be overkill. Recently, however, I found myself in a situation where writing a component from scratch in Vanilla JS without the help of a framework would’ve been too complicated and messy.

Lightweight Frameworks

My task was to add multiple filters, sorting and pagination to an existing list of items. I didn’t want to use a JavaScript Framework like Vue or React, only because I needed help in some places on my site, and I didn’t want to change my stack. I consulted Twitter, and people suggested minimal frameworks like lit, petite-vue, hyperscript, htmx or Alpine.js. I went with Alpine because it sounded like it was exactly what I was looking for:

“Alpine is a rugged, minimal tool for composing behavior directly in your markup. Think of it like jQuery for the modern web. Plop in a script tag and get going.”

Alpine.js

Alpine is a lightweight (~7KB) collection of 15 attributes, 6 properties, and 2 methods. I won’t go into the basics of it (check out this article about Alpine by Hugo Di Francesco or read the Alpine docs), but let me quickly introduce you to Alpine:

Note: You can skip this intro and go straight to the main content of the article if you’re already familiar with Alpine.js.

Let’s say we want to turn a simple list with many items into a disclosure widget. You could use the native HTML elements: details and summary for that, but for this exercise, I’ll use Alpine.

By default, with JavaScript disabled, we show the list, but we want to hide it and allow users to open and close it by pressing a button if JavaScript is enabled:

<h2>Beastie Boys Anthology</h2>
<p>The Sounds of Science is the first anthology album by American rap rock group Beastie Boys composed of greatest hits, B-sides, and previously unreleased tracks.</p>
<ol>
  <li>Beastie Boys</li>
  <li>Slow And Low</li>
  <li>Shake Your Rump</li>
  <li>Gratitude</li>
  <li>Skills To Pay The Bills</li>
  <li>Root Down</li>
  <li>Believe Me</li>
  …
</ol>

First, we include Alpine using a script tag. Then we wrap the list in a div and use the x-data directive to pass data into the component. The open property inside the object we passed is available to all children of the div:

<div x-data="{ open: false }">
  <ol>
    <li>Beastie Boys</li>
    <li>Slow And Low</li>
    …
  </ol>
</div>

<script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>

We can use the open property for the x-show directive, which determines whether or not an element is visible:

<div x-data="{ open: false }">
  <ol x-show="open">
    <li>Beastie Boys</li>
    <li>Slow And Low</li>
    …
  </ol>
</div>

Since we set open to false, the list is hidden now.

Next, we need a button that toggles the value of the open property. We can add events by using the x-on:click directive or the shorter @-Syntax @click:

<div x-data="{ open: false }">
  <button @click="open = !open">Tracklist</button>
  
  <ol x-show="open">
    <li>Beastie Boys</li>
    <li>Slow And Low</li>
    …
  </ol>
</div>

Pressing the button, open now switches between false and true and x-show reactively watches these changes, showing and hiding the list accordingly.

While this works for keyboard and mouse users, it’s useless to screen reader users, as we need to communicate the state of our widget. We can do that by toggling the value of the aria-expanded attribute:

<button @click="open = !open" :aria-expanded="open">
  Tracklist
</button>

We can also create a semantic connection between the button and the list using aria-controls for screen readers that support the attribute:

<button @click="open = ! open" :aria-expanded="open" aria-controls="tracklist">
  Tracklist
</button>
<ol x-show="open" id="tracklist">
  …
</ol>

Here’s the final result:

See the Pen [Simple disclosure widget with Alpine.js](https://codepen.io/smashingmag/pen/xxpdzNz) by Manuel Matuzovic.

See the Pen Simple disclosure widget with Alpine.js by Manuel Matuzovic.

Pretty neat! You can enhance existing static content with JavaScript without having to write a single line of JS. Of course, you may need to write some JavaScript, especially if you’re working on more complex components.

A Static, Paginated List

Okay, now that we know the basics of Alpine.js, I’d say it’s time to build a more complex component.

Note: You can take a look at the final result before we get started.

I want to build a paginated list of my vinyl records that works without JavaScript. We’ll use the static site generator eleventy (or short “11ty”) for that and Alpine.js to enhance it by making the list filterable.

A picture with vinyls standing in a row
Anyone else here also a fan of vinyl records? ;-) (Large preview)

Setup

Before we get started, let’s set up our site. We need:

  • a project folder for our site,
  • 11ty to generate HTML files,
  • an input file for our HTML,
  • a data file that contains the list of records.

On your command line, navigate to the folder where you want to save the project, create a folder, and cd into it:

cd Sites # or wherever you want to save the project
mkdir myrecordcollection # pick any name
cd myrecordcollection

Then create a package.json file and install eleventy:

npm init -y
npm install @11ty/eleventy

Next, create an index.njk file (.njk means this is a Nunjucks file; more about that below) and a folder _data with a records.json:

touch index.njk
mkdir _data
touch _data/records.json

You don’t have to do all these steps on the command line. You can also create folders and files in any user interface. The final file and folder structure looks like this:

A screenshot of the final file and the folder structure
(Large preview)

Adding Content

11ty allows you to write content directly into an HTML file (or Markdown, Nunjucks, and other template languages). You can even store data in the front matter or in a JSON file. I don’t want to manage hundreds of entries manually, so I’ll store them in the JSON file we just created. Let’s add some data to the file:

[
  {
    "artist": "Akne Kid Joe",
    "title": "Die große Palmöllüge",
    "year": 2020
  },
  {
    "artist": "Bring me the Horizon",
    "title": "Post Human: Survial Horror",
    "year": 2020
  },
  {
    "artist": "Idles",
    "title": "Joy as an Act of Resistance",
    "year": 2018
  },
  {
    "artist": "Beastie Boys",
    "title": "Licensed to Ill",
    "year": 1986
  },
  {
    "artist": "Beastie Boys",
    "title": "Paul's Boutique",
    "year": 1989
  },
  {
    "artist": "Beastie Boys",
    "title": "Check Your Head",
    "year": 1992
  },
  {
    "artist": "Beastie Boys",
    "title": "Ill Communication",
    "year": 1994
  }
]

Finally, let’s add a basic HTML structure to the index.njk file and start eleventy:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
  <title>My Record Collection</title>
</head>
<body>
  <h1>My Record Collection</h1>
    
</body>
</html>

By running the following command you should be able to access the site at http://localhost:8080:

eleventy --serve
A screenshot where the site showing the heading ‘My Record Collection
Eleventy running on port :8080. The site just shows the heading ‘My Record Collection’. (Large preview)

Displaying Content

Now let’s take the data from our JSON file and turn it into HTML. We can access it by looping over the records object in nunjucks:

<div class="collection">
  <ol>
    {% for record in records %}
    <li>
      <strong>{{ record.title }}</strong><br>
      Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
    </li>
    {% endfor %}
  </ol>
</div>
A screenshot with 7 Records listed, each with their title, artist and release date
7 Records listed, each with their title, artist and release date. (Large preview)

Pagination

Eleventy supports pagination out of the box. All we have to do is add a frontmatter block to our page, tell 11ty which dataset it should use for pagination, and finally, we have to adapt our for loop to use the paginated list instead of all records:

---
pagination:
  data: records
  size: 5
---
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
      
    <title>My Record Collection</title>
  </head>
  <body>
    <h1>My Record Collection</h1>
  
    <div class="collection">
      <p id="message">Showing <output>{{ records.length }} records</output></p>
      
      <div aria-labelledby="message" role="region">
        <ol class="records">
          {% for record in pagination.items %}
          <li>
            <strong>{{ record.title }}</strong><br>
            Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
          </li>
          {% endfor %}
        </ol>
      </div>
    </div>
  </body>
</html>

If you access the page again, the list only contains 5 items. You can also see that I’ve added a status message (ignore the output element for now), wrapped the list in a div with the role “region”, and that I’ve labelled it by creating a reference to #message using aria-labelledby. I did that to turn it into a landmark and allow screen reader users to access the list of results directly using keyboard shortcuts.

Next, we’ll add a navigation with links to all pages created by the static site generator. The pagination object holds an array that contains all pages. We use aria-current="page" to highlight the current page:

<nav aria-label="Select a page">
  <ol class="pages">
    {% for page_entry in pagination.pages %}
      {%- set page_url = pagination.hrefs[loop.index0] -%}
      <li>
        <a href="{{ page_url }}"{% if page.url == page_url %} aria-current="page"{% endif %}>
          Page {{ loop.index }}
        </a>
      </li>
    {% endfor %}
  </ol>
</nav>

Finally, let’s add some basic CSS to improve the styling:

body {
  font-family: sans-serif;
  line-height: 1.5;
}

ol {
  list-style: none;
  margin: 0;
  padding: 0;
}

.records > * + * {
  margin-top: 2rem;
}

h2 {
  margin-bottom: 0;
}

nav {
  margin-top: 1.5rem;
}

.pages {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.pages a {
  border: 1px solid #000000;
  padding: 0.5rem;
  border-radius: 5px;
  display: flex;
  text-decoration: none;
}

.pages a:where([aria-current]) {
  background-color: #000000;
  color: #ffffff;
}

.pages a:where(:focus, :hover) {
  background-color: #6c6c6c;
  color: #ffffff;
}
A screenshot with 7 Records listed, each with their title, artist and release date, with links to all pages and a highlighted current page
(Large preview)

You can see it in action in the live demo and you can check out the code on GitHub.

This works fairly well with 7 records. It might even work with 10, 20, or 50, but I have over 400 records. We can make browsing the list easier by adding filters.

More after jump! Continue reading below ↓

A Dynamic Paginated And Filterable List

I like JavaScript, but I also believe that the core content and functionality of a website should be accessible without it. This doesn’t mean that you can’t use JavaScript at all, it just means that you start with a basic server-rendered foundation of your component or site, and you add functionality layer by layer. This is called progressive enhancement.

Our foundation in this example is the static list created with 11ty, and now we add a layer of functionality with Alpine.

First, right before the closing body tag, we reference the latest version (as of writing 3.9.1) of Alpine.js:

 <script src="https://unpkg.com/alpinejs@3.9.1/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
</body>

Note: Be careful using a third-party CDN, this can have all kinds of negative implications (performance, privacy, security). Consider referencing the file locally or importing it as a module.
In case you’re wondering why you don’t see the Subresource Integrity hash in the official docs, it’s because I’ve created and added it manually.

Since we’re moving into JavaScript-world, we need to make our records available to Alpine.js. Probably not the best, but the quickest solution is to create a .eleventy.js file in your root folder and add the following lines:

module.exports = function(eleventyConfig) {
    eleventyConfig.addPassthroughCopy("_data");
};

This ensures that eleventy doesn’t just generate HTML files, but it also copies the contents of the _data folder into our destination folder, making it accessible to our scripts.

Fetching Data

Just like in the previous example, we’ll add the x-data directive to our component to pass data:

<div class="collection" x-data="{ records: [] }">
</div>

We don’t have any data, so we need to fetch it as the component initialises. The x-init directive allows us to hook into the initialisation phase of any element and perform tasks:

<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }">
  <div x-text="records"></div>
  […]
</div>

If we output the results directly, we see a list of [object Object]s, because we’re fetching and receiving an array. Instead, we should iterate over the list using the x-for directive on a template tag and output the data using x-text:

<template x-for="record in records">
  <li>
    <strong x-text="record.title"></strong><br>
    Released in <time :datetime="record.year" x-text="record.year"></time> by <span x-text="record.artist"></span>.
  </li>
</template>
The `