Why a List Component Needs Layers
Building a paginated list is a common task, but doing it in a way that works for everyone—with or without JavaScript—requires a deliberate approach. Static site generators like Eleventy handle content well, but adding filters, sorting, and pagination usually means pulling in a heavy framework. Alpine.js offers a middle ground: it enhances existing HTML without requiring you to rewrite your markup.
Alpine is roughly 7KB and consists of 15 attributes, 6 properties, and 2 methods. You include it with a script tag and compose behavior directly in your markup. For example, turning a simple list into a disclosure widget takes only a few directives, and you can maintain accessibility by toggling aria-expanded and connecting elements with aria-controls.
Starting With Static HTML
Before adding any JavaScript, the foundation should be a fully functional static page. Using Eleventy, you can generate this from a Nunjucks template and a JSON data file. The setup is minimal:
- A project folder for the site.
- Eleventy to generate HTML files.
- An
index.njkinput file for the page. - A
_data/records.jsonfile containing the list of records.
Install Eleventy and run it locally to see the site at http://localhost:8080. With the JSON data in place, loop over the records in the template to output the list.
Adding Pagination Without JavaScript
Eleventy has built-in pagination support. Define the pagination in the front matter, pointing to your dataset and setting the number of items per page. The template loop then iterates over the paginated subset instead of the full records array. Add a status message with an output element (ignored for now) and wrap the list in a div with role="region", labelling it with aria-labelledby. This creates a landmark that screen reader users can jump to directly.
Generate page navigation links from Eleventy’s pagination object. Use aria-current="page" to indicate the current page. Basic CSS finishes the static version, which works with any number of records.
The Problem With Many Records
This approach works acceptably for small datasets. But with over 400 entries, browsing becomes tedious. Pagination helps, but without filtering, users must click through many pages to find anything specific.
The solution is to enhance the static list with client-side filtering. That’s where Alpine.js comes in. It can sort, filter, and paginate the existing DOM content without requiring a server round trip or a full framework. The base HTML still works without JavaScript; Alpine progressively enhances it when available.
Adding Interactivity With Alpine
The static list we’ve built so far works without JavaScript, but adding a layer of interactivity makes it more usable. To do this without losing the no-JS foundation, we reference Alpine.js (version 3.9.1 as of writing) just before the closing body tag:
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
</body>
Note: Referencing a third-party CDN can have negative implications for performance, privacy, or security. Consider hosting the file locally or importing it as a module instead. The official docs don’t show a Subresource Integrity hash because I created and added it manually.
Because Alpine runs in the browser, our record data must be available to it. The quickest approach is to create a .eleventy.js file in the root folder with the following content, which tells Eleventy to copy the _data folder into the output directory:
module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("_data");
};
Fetching and Rendering Data
On the component, we add the x-data directive to signal Alpine’s scope. Since we don’t have data inline, we fetch it during initialization using x-init:
<div class="collection" x-init="records = await (await fetch('/_data/records.json')).json()" x-data="{ records: [] }">
<div x-text="records"></div>
[…]
</div>
The fetched content is an array, so outputting records directly would show a list of [object Object] entries. Instead, we iterate with x-for on a template element and display values with 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<template>HTML element is a mechanism for holding HTML that is not to be rendered immediately when a page is loaded but may be instantiated subsequently during runtime using JavaScript."
MDN:<template>: The Content Template Element
Using Alpine’s directives inline works for simple cases, but the markup becomes hard to manage quickly. Moving data and logic into a separate Alpine component object keeps things clean. Instead of passing data directly, we reference the component in x-data, then fetch the JSON file during initialization:
<div class="collection" x-data="collection">
[…]
</div>
[…]
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
},
init() {
this.getRecords();
}
}))
})
</script>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js" integrity="sha384-mDHH3kdyMS0F6QcfHCxEgPMMjssTurzucc7Jct3g1GOfB4p7PxJuugPP1NOLvE7I" crossorigin="anonymous"></script>
Because the original 11ty-rendered list is still in the DOM, we have duplicated records. Alpine’s x-ignore directive marks elements it should skip. We add that directive to the static list items, then hide them with CSS once data is loaded — a class on the html element and a style rule handle the rest:
<style>
.alpine [x-ignore] {
display: none;
}
</style>
[…]
{%- for record in pagination.items %}
<li x-ignore>
<strong>{{ record.title }}</strong><br>
Released in <time datetime="{{ record.year }}">{{ record.year }}</time> by {{ record.artist }}.
</li>
{%- endfor %}
[…]
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
</script>
Pagination Logic
Before adding filters, we implement paging ourselves. This requires:
- the number of items per page (
itemsPerPage), - the current page index (
currentPage), - the total number of pages (
numOfPages), - a dynamic subset of the full dataset (
page).
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
records: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages: // total number of pages,
page: // paged items
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
Items per page is fixed at 5, and the current page starts at 0. The total page count is the rounded-up division of items by items per page:
numOfPages() {
return Math.ceil(this.records.length / this.itemsPerPage)
// 7 / 5 = 1.4
// Math.ceil(7 / 5) = 2
},
To extract the items for the current page, JavaScript’s slice() method works well:
page() {
return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
// this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage
// Page 1: 0 * 5, (0 + 1) * 5 (=> slice(0, 5);)
// Page 2: 1 * 5, (1 + 1) * 5 (=> slice(5, 10);)
// Page 3: 2 * 5, (2 + 1) * 5 (=> slice(10, 15);)
}
We adapt the loop to iterate over page rather than records so only the visible subset renders:
<ol class="records">
<template x-for="record in page">
<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>
</ol>
Now we add pagination links with another template and x-for loop. Attaching a click handler on each link prevents a full page reload and updates the current page number:
<a href="/" @click.prevent="currentPage = idx - 1"></a>
This works in the browser once more entries are in the JSON file — a fuller version is available on GitHub.
Filtering by Artist and Decade
To filter the list, we wrap two select elements in a fieldset. Each select gets an x-model directive to bind its value to Alpine data:
<fieldset class="filters">
<legend>Filter by</legend>
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist">
<option value="">All</option>
</select>
<label for="decade">Decade</label>
<select id="decade" x-model="filters.year">
<option value="">All</option>
</select>
</fieldset>
Those data fields exist in the component:
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
filters: {
year: '',
artist: '',
},
records: [],
itemsPerPage: 5,
currentPage: 0,
numOfPages() {
return Math.ceil(this.records.length / this.itemsPerPage)
},
page() {
return this.records.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
document.documentElement.classList.add('alpine');
},
init() {
this.getRecords();
}
}))
})
When the selected value changes, filters.artist and filters.year update automatically. The next step populates both selects dynamically from the records. The records array is mapped to plain strings, deduplicated with [...new Set()], and sorted alphabetically. For the decade option, I cut the last digit off the year so the filter is decade-level rather than year-specific:
document.addEventListener('alpine:init', () => {
Alpine.data('collection', () => ({
artists: [],
decades: [],
// […]
async getRecords() {
this.records = await (await fetch('/_data/records.json')).json();
this.artists = [...new Set(this.records.map(record => record.artist))].sort();
this.decades = [...new Set(this.records.map(record => record.year.toString().slice(0, -1)))].sort();
document.documentElement.classList.add('alpine');
},
// […]
}))
})
We fill the select elements with template and x-for:
<label for="artist">Artist</label>
<select id="artist" x-model="filters.artist">
<option value="">All</option>
<template x-for="artist in artists">
<option x-text="artist"></option>
</template>
</select>
<label for="decade">Decade</label>
<select id="decade" x-model="filters.year">
<option value="">All</option>
<template x-for="year in decades">
<option :value="year" x-text="`${year}0`"></option>
</template>
</select>
The actual filtering iterates all records, checks whether each filter is set, and keeps only those records whose field matches the selected value:
get filteredRecords() {
const filtered = this.records.filter((item) => {
for (var key in this.filters) {
if (this.filters[key] === '') {
continue
}
if(!String(item[key]).includes(this.filters[key])) {
return false
}
}
return true
});
return filtered
}
To take effect, numOfPages() and page() must work on the filtered set rather than the full array:
numOfPages() {
return Math.ceil(this.filteredRecords.length / this.itemsPerPage)
},
page() {
return this.filteredRecords.slice(this.currentPage * this.itemsPerPage, (this.currentPage + 1) * this.itemsPerPage)
},See the Pen [Pagination + Filter with Alpine.js Step 6](https://codepen.io/smashingmag/pen/GRymwQZ) by Manuel Matuzovic.
Fixing a State Bug
If a user opens page 6, then selects “1990” as a filter, no results appear. The filter still assumes the user is on page 6, but with “1990” active there is no page 6 — and the filter view lands on page 1 anyway. The fix resets currentPage whenever a filter changes. Alpine’s $watch magic method makes this straightforward:
init() {
this.getRecords();
this.$watch('filters', filter => this.currentPage = 0);
}
Hiding the Form Without JavaScript
Because the filters only operate when JavaScript is enabled, the entire form should be invisible otherwise. The .alpine class added earlier handles that. I use visibility: hidden rather than the hidden attribute so the layout doesn’t shift while Alpine boots:
<fieldset class="filters" hidden>
[…]
</fieldset>.filters {
display: block;
}
html:not(.alpine) .filters {
visibility: hidden;
}
Communicating Results to Screen Readers
The status line at the top still says “Showing 7 records” even when the list is filtered or paginated. Two changes address this: bind data to the output element to reflect the current page and filters, and announce updates for assistive technology.
<p id="message">Showing <output x-text="message">{{ records.length }} records</output></p>Alpine.data('collection', () => ({
message() {
return `${this.filteredRecords.length} records`;
},
// […]
There are two standard approaches for announcing changes:
- Turn an element into a live region with
aria-live; its content is announced every time it updates. - Make the region focusable and move focus to it after changes, so the labelled element’s name and role are announced.
The existing output element is already an implicit live region, so no extra attribute is necessary. For the focusable approach, x-ref lets us reference the element in Alpine:
<a @click.prevent="currentPage = idx - 1; $nextTick(() => { $refs.region.focus(); $refs.region.scrollIntoView(); });" :href="`/${idx}`" x-text="`Page ${idx}`" :aria-current="idx === currentPage + 1 ? 'page' : false">
My final choice combines both methods:
- Filtering updates the live region — we do not move focus.
- When the user changes the page, focus moves to the list.
See the final result to test it.
Note: When you filter by an artist with exactly one record, the message shows “1 records”. Choosing another artist with also one record produces identical text in the output element, so nothing new is announced. Whether you treat that as a bug or a way to reduce repetitive announcements depends on user testing.
Where This Technique Can Go Further
The balance of work here might look disproportionate: building a native, accessible list and then layering JavaScript behavior on top of it. But if, like me, you don't fully trust JavaScript to do the heavy lifting, the payoff justifies the extra steps. Looking at the final CodePen or the complete code on GitHub, the added complexity is modest. A minimal framework such as Alpine.js makes it straightforward to turn a static, progressively enhanced component into a reactive one without rewriting the foundation.
The solution meets its goals, but there is clear room for refinement. The most valuable improvements would be:
- Smarter pagination. The current implementation is basic; it could support a maximum page count, previous and next links, and better navigation cues.
- User-controlled page size. Letting visitors decide how many items appear per page would improve usability for different content densities.
- Sorting options. Adding sortable columns or controls would extend the list's usefulness beyond simple filtering.
- History API integration. Persisting filter and pagination state in the URL would allow deep linking and back-button support.
- Reduced content shift. Layout stability during filtering and pagination can still be improved.
- Broader testing. Real-world usage with browsers and screen readers is needed to validate accessibility assumptions.
A note on the markup itself: Alpine.js relies on custom x- attributes, which technically produce invalid HTML. That caveat bothers me as much as it might bother you, but since it has no impact on end users, it is an acceptable trade-off.
Related Reading
If you want to build on these patterns, two pieces of writing were especially helpful in shaping the approach above: Søren Birkemeyer's guide to building filterable lists, and Scott O'Hara's analysis of dynamic search results and content updates. Both go deeper into the interaction and accessibility trade-offs that come with live-updating interfaces.
For adjacent techniques, the following articles are worth reviewing: managing top-layer transitions and the CSS display property, securing full-stack applications with OWASP guidance, designing multistep forms that feel coherent, and the role of graceful degradation in accessible interface design.




