DEV Community

Cover image for How to Build an Autocomplete Component from scratch in Vanilla JS
Alexander Pechkarev
Alexander Pechkarev

Posted on • Edited on

How to Build an Autocomplete Component from scratch in Vanilla JS

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>
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
    });
  };
Enter fullscreen mode Exit fullscreen mode

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');
        }
    });
};
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
developeraromal profile image
Aromal

Thanks for making this I was looking for this

Collapse
 
masterdevsabith profile image
Muhammed Sabith

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

Collapse
 
alexpechkarev profile image
Alexander Pechkarev

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.