I've always found that one of the best ways to truly understand a concept is to build it from scratch. That's why I wrote this guide. I wanted to break down the autocomplete—a component we use every day—into its fundamental parts. My hope is that by building it together, you'll not only gain a powerful new tool for your projects but also a deeper appreciation for the details that create a great user experience. Let's dive in!
Autocomplete is a feature that users have come to expect. It makes interfaces faster, smarter, and more user-friendly. While it might seem complex, building a professional-grade autocomplete component from scratch is an excellent way to sharpen your vanilla JavaScript skills.
In this tutorial, we'll build a fully-featured, accessible, and performant autocomplete component. We will cover:
- Fetching data from a live API (
jsonplaceholder.typicode.com
). - Filtering results and dynamically rendering a suggestions list.
- Implementing keyboard navigation (Up, Down, Enter, Escape).
- Adding debouncing to prevent excessive API calls.
- Ensuring accessibility with ARIA attributes.
- Styling the component with Tailwind CSS.
Finally, we'll see how the principles we've learned apply to more specialised libraries like places-autocomplete-js
for handling Google Maps address lookups.
The Final Result
Here's what we'll be building: a clean, fast, and accessible autocomplete for searching users.
The HTML Structure (autocomplete.html
)
We'll start with a simple but powerful HTML structure. The key is the <input>
element, which we'll augment with several ARIA attributes to make it accessible.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Autocomplete Example</title>
<!-- We are using Tailwind CSS for styling -->
<link href="./index.css" rel="stylesheet">
<style>
/* Custom styles for the suggestion list */
.suggestions-list {
border: 1px solid #ddd;
border-top: none;
list-style: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.suggestions-list li {
padding: 10px;
cursor: pointer;
}
.suggestions-list li.selected {
background-color: #f0f0f0;
}
.suggestions-list .no-results {
padding: 10px;
color: #888;
cursor: default;
}
</style>
</head>
<body>
<div class="p-10">
<h1 class="text-2xl font-bold mb-4">Autocomplete Example</h1>
<div class="autocomplete-container relative w-full max-w-xs">
<input
type="text"
id="autocomplete-input"
class="border-2 border-gray-300 rounded-md p-2 w-full"
placeholder="Search for a user..."
autocomplete="off"
role="combobox"
aria-autocomplete="list"
aria-haspopup="true"
aria-expanded="false"
aria-controls="suggestions-list"
/>
<ul id="suggestions-list" class="suggestions-list absolute w-full bg-white" role="listbox"></ul>
</div>
</div>
<script src="app.js" type="module"></script>
</body>
</html>
The ARIA attributes tell screen readers that this input is a combobox
that will display a list
of suggestions, whether the list is expanded
, and which element it controls
.
The JavaScript Logic (app.js
)
This is where the magic happens. Our app.js
file handles everything from fetching data to managing user interaction.
1. Fetching and Debouncing
First, we fetch the user data from the JSONPlaceholder API. To avoid overwhelming the API (and our own code) with requests as the user types, we wrap our input handler in a debounce
function. This ensures the search logic only runs after the user has paused typing for 300ms.
// Debounce function to limit the rate of API calls
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
};
// Handle input event
const handleInput = (e) => {
const query = e.target.value.toLowerCase();
if (query.length < 1) {
clearSuggestions();
return;
}
const filteredSuggestions = allUsers.filter(user =>
user.name.toLowerCase().includes(query)
);
renderSuggestions(filteredSuggestions);
};
// Attach the debounced handler
input.addEventListener('input', debounce(handleInput, 300));
2. Rendering Suggestions
The renderSuggestions
function dynamically creates the list of <li>
elements. For each suggestion, we set an id
and role="option"
for accessibility. We also update aria-expanded
to true
so screen readers know the list is now visible.
// Render suggestions in the dropdown
const renderSuggestions = (suggestions) => {
// clear previous suggestions
clearSuggestions();
// set aria-expanded attribute
input.setAttribute("aria-expanded", "true");
// Show "No results found" message if no suggestions
if (suggestions.length === 0) {
const li = document.createElement("li");
li.textContent = "No results found";
li.classList.add("no-results");
suggestionsList.appendChild(li);
return;
}
// iterate over suggestions and create list items
// for each suggestion, create a list item and append it to the suggestions list
suggestions.forEach((suggestion, index) => {
const li = document.createElement("li");
li.id = `suggestion-${index}`;
li.setAttribute("role", "option");
li.innerHTML = `
<span class="main-text">${suggestion.name}</span>
<span class="secondary-text text-gray-500 block text-sm">${suggestion.email}</span>
`;
li.dataset.index = index;
// add event listener for suggestion click
// when a suggestion is clicked, set the input value to the suggestion name
li.addEventListener("click", () => {
input.value = suggestion.name;
clearSuggestions();
});
// append the list item to the suggestions list
suggestionsList.appendChild(li);
});
};
3. Keyboard Navigation and Accessibility
A professional autocomplete must be fully keyboard-navigable. We listen for ArrowDown
, ArrowUp
, Enter
, and Escape
.
When the user navigates with the arrow keys, the updateSelectedSuggestion
function highlights the active item and, crucially, updates the aria-activedescendant
attribute on the input. This tells the screen reader which option is currently focused, providing a seamless experience for all users.
// Update the selected suggestion
// Highlight the selected suggestion
const updateSelectedSuggestion = () => {
const suggestions = suggestionsList.querySelectorAll('li:not(.no-results)');
suggestions.forEach((li, index) => {
if (index === selectedSuggestionIndex) {
li.classList.add('selected');
// This is key for accessibility!
input.setAttribute('aria-activedescendant', li.id);
} else {
li.classList.remove('selected');
}
});
};
The Complete app.js
// Initialise the application
document.addEventListener("DOMContentLoaded", () => {
// initialise variables
const input = document.getElementById("autocomplete-input");
const suggestionsList = document.getElementById("suggestions-list");
let allUsers = [];
let selectedSuggestionIndex = -1;
// Debounce function to limit the rate of API calls
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
};
// fetch users from API
const fetchUsers = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
allUsers = await response.json();
} catch (error) {
console.error("Error fetching users:", error);
}
};
// Render suggestions in the dropdown
const renderSuggestions = (suggestions) => {
// clear previous suggestions
clearSuggestions();
// set aria-expanded attribute
input.setAttribute("aria-expanded", "true");
// Show "No results found" message if no suggestions
if (suggestions.length === 0) {
const li = document.createElement("li");
li.textContent = "No results found";
li.classList.add("no-results");
suggestionsList.appendChild(li);
return;
}
// iterate over suggestions and create list items
// for each suggestion, create a list item and append it to the suggestions list
suggestions.forEach((suggestion, index) => {
const li = document.createElement("li");
li.id = `suggestion-${index}`;
li.setAttribute("role", "option");
li.innerHTML = `
<span class="main-text">${suggestion.name}</span>
<span class="secondary-text text-gray-500 block text-sm">${suggestion.email}</span>
`;
li.dataset.index = index;
// add event listener for suggestion click
// when a suggestion is clicked, set the input value to the suggestion name
li.addEventListener("click", () => {
input.value = suggestion.name;
clearSuggestions();
});
// append the list item to the suggestions list
suggestionsList.appendChild(li);
});
};
// clear suggestions
const clearSuggestions = () => {
suggestionsList.innerHTML = "";
selectedSuggestionIndex = -1;
// set aria-expanded attribute
input.setAttribute("aria-expanded", "false");
// remove aria-activedescendant attribute
input.removeAttribute("aria-activedescendant");
};
// Update the selected suggestion
// Highlight the selected suggestion
const updateSelectedSuggestion = () => {
const suggestions = suggestionsList.querySelectorAll("li:not(.no-results)");
// find the currently selected suggestion and highlight it
suggestions.forEach((li, index) => {
if (index === selectedSuggestionIndex) {
// add selected class
li.classList.add("selected");
// scroll the selected suggestion into view
li.scrollIntoView({ block: "nearest" });
// set aria-activedescendant attribute
input.setAttribute("aria-activedescendant", li.id);
} else {
// remove selected class
li.classList.remove("selected");
}
});
};
// Handle input event
const handleInput = (e) => {
// Get the input value
const query = e.target.value.toLowerCase();
// Clear suggestions if input is empty
if (query.length < 1) {
clearSuggestions();
return;
}
// Filter suggestions based on input
const filteredSuggestions = allUsers.filter((user) =>
user.name.toLowerCase().includes(query)
);
// render the filtered suggestions
renderSuggestions(filteredSuggestions);
};
// Attach the debounced handler
input.addEventListener("input", debounce(handleInput, 300));
// Handle keyboard navigation
input.addEventListener("keydown", (e) => {
// get the current suggestions
const suggestions = suggestionsList.querySelectorAll("li:not(.no-results)");
// check if there are any suggestions and if the suggestions list is open
if (
suggestions.length === 0 ||
input.getAttribute("aria-expanded") === "false"
)
return;
// Handle arrow key down navigation and move down the suggestions
if (e.key === "ArrowDown") {
e.preventDefault();
selectedSuggestionIndex =
(selectedSuggestionIndex + 1) % suggestions.length;
updateSelectedSuggestion();
// Handle arrow key up navigation and move up the suggestions
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedSuggestionIndex =
(selectedSuggestionIndex - 1 + suggestions.length) % suggestions.length;
updateSelectedSuggestion();
// Handle enter key and select the suggestion
} else if (e.key === "Enter") {
e.preventDefault();
if (selectedSuggestionIndex > -1) {
suggestions[selectedSuggestionIndex].click();
}
// Handle escape key and close the suggestions
} else if (e.key === "Escape") {
clearSuggestions();
}
});
// Fetch users
fetchUsers();
});
Applying These Principles in the Real World
The autocomplete component we've built is a powerful, general-purpose foundation. These features—debouncing, keyboard navigation, and accessibility—can be adapted for countless applications, from searching products to tagging articles.
As a prime example of a specialised application, consider places-autocomplete-js
This library acts as a wrapper for the Google Maps Places (New) API. It provides a convenient autocomplete gateway for the user to search, filter, and navigate the complex responses from the API and select a desired address. It takes care of the API's specific requirements (like session tokens and structured data), so the developer can focus on using the final address data.
Let's Discuss!
Thanks for reading! I hope you found this tutorial helpful. Building components like this is a journey, and there are always new things to learn.
- What are your thoughts? Would you approach this differently?
- Have any questions? Leave a comment below.
- How would you use this? I'd love to hear how you might use these techniques in your own projects.
If you found this article useful, please consider sharing it on social media or with your co-workers.
Conclusion
Building a feature-rich autocomplete from scratch is a fantastic way to master core JavaScript skills. The techniques covered here provide a solid foundation for creating interactive and user-friendly components.
Top comments (3)
Thanks for making this I was looking for this
Thanks! It was useful but showing functions with less explanation was a slight overwhelming. I'll try to implement it in a python CLI tool
Thank you for the feedback! I've updated the post to include the complete
app.js
in its own section, which I hope makes the flow clearer.