How to Filter a List with JavaScript

This recipe builds a search box that live-filters a list of <li> items by hiding the ones that don't match as the user types.

The filtering technique

The core idea is small: on every keystroke in the search input, loop over every list item, compare its text against the typed query, and show or hide it based on whether it matches. Nothing is removed from the page and nothing is re-fetched — the items already exist in the DOM, and filtering just toggles their display style. Because the comparison and the DOM update both happen synchronously inside the input event handler, the list appears to filter instantly as the user types.

  • Get a reference to the search <input> and to all of the <li> elements once, outside the event handler
  • Listen for the input event, which fires on every keystroke, paste, and edit — unlike change, which only fires after the field loses focus
  • Inside the handler, lowercase the current input value so the match is case-insensitive
  • For each <li>, lowercase its textContent and check whether it .includes() the query
  • Set style.display = 'none' on non-matches and style.display = '' on matches, letting the stylesheet's own display value take back over

Example

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Filter a List</title>
<style>
  body {
    font-family: system-ui, sans-serif;
    padding: 40px;
    background: #f5f5f7;
    max-width: 360px;
  }

  input[type="search"] {
    width: 100%;
    padding: 8px 12px;
    font-size: 15px;
    border: 1px solid #ccc;
    border-radius: 6px;
    box-sizing: border-box;
    margin-bottom: 12px;
  }

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

  li {
    padding: 8px 12px;
    background: #fff;
    border-bottom: 1px solid #eee;
  }
</style>
</head>
<body>

  <input type="search" id="filterInput" placeholder="Search fruit…" aria-label="Search fruit">

  <ul id="fruitList">
    <li>Apple</li>
    <li>Banana</li>
    <li>Cherry</li>
    <li>Grapefruit</li>
    <li>Mango</li>
    <li>Orange</li>
    <li>Pineapple</li>
  </ul>

  <script>
    const input = document.getElementById('filterInput');
    const items = document.querySelectorAll('#fruitList li');

    input.addEventListener('input', () => {
      const query = input.value.trim().toLowerCase();

      items.forEach((item) => {
        const text = item.textContent.toLowerCase();
        const matches = text.includes(query);
        item.style.display = matches ? '' : 'none';
      });
    });
  </script>

</body>
</html>

Two small details do most of the work here. Calling .toLowerCase() on both the query and the item text means "Cherry" matches a search for "cherry" or "CHERRY" — without it, filtering would be case-sensitive and feel broken to most users. And .includes() checks for a substring anywhere in the text, not just at the start, so typing "berry" still correctly finds it in the middle of "Blueberry". Trimming the query with .trim() avoids a leading space making an otherwise-matching item briefly disappear.

Note: For a list this size, filtering on every keystroke is effectively free. For a very large list (thousands of items) or a search that triggers a network request, debounce the handler with setTimeout/clearTimeout so the expensive work only runs after the user pauses typing for a moment, rather than on every single character.

Example

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Filter a List with Result Count</title>
<style>
  body {
    font-family: system-ui, sans-serif;
    padding: 40px;
    background: #f5f5f7;
    max-width: 360px;
  }

  input[type="search"] {
    width: 100%;
    padding: 8px 12px;
    font-size: 15px;
    border: 1px solid #ccc;
    border-radius: 6px;
    box-sizing: border-box;
  }

  #status {
    margin: 8px 0;
    font-size: 13px;
    color: #666;
  }

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

  li {
    padding: 8px 12px;
    background: #fff;
    border-bottom: 1px solid #eee;
  }

  #noResults {
    display: none;
    padding: 8px 12px;
    color: #999;
    font-style: italic;
  }
</style>
</head>
<body>

  <input type="search" id="filterInput" placeholder="Search fruit…" aria-label="Search fruit">
  <p id="status" aria-live="polite"></p>

  <ul id="fruitList">
    <li>Apple</li>
    <li>Banana</li>
    <li>Cherry</li>
    <li>Grapefruit</li>
    <li>Mango</li>
    <li>Orange</li>
    <li>Pineapple</li>
  </ul>
  <p id="noResults">No fruit matches your search.</p>

  <script>
    const input = document.getElementById('filterInput');
    const items = Array.from(document.querySelectorAll('#fruitList li'));
    const status = document.getElementById('status');
    const noResults = document.getElementById('noResults');

    input.addEventListener('input', () => {
      const query = input.value.trim().toLowerCase();
      let visibleCount = 0;

      items.forEach((item) => {
        const matches = item.textContent.toLowerCase().includes(query);
        item.style.display = matches ? '' : 'none';
        if (matches) visibleCount++;
      });

      status.textContent = query ? `${visibleCount} result${visibleCount === 1 ? '' : 's'} found` : '';
      noResults.style.display = visibleCount === 0 ? 'block' : 'none';
    });
  </script>

</body>
</html>

Accessibility and performance details

MethodDescription
textContentReads all text inside an element, including hidden children; fast because it doesn't force layout
innerTextReads only rendered, visible text and respects CSS like text-transform; slower because it forces a layout to know what's actually visible
.includes()Returns true/false for whether one string contains another; readable and works everywhere modern JS runs
.indexOf() !== -1Older equivalent to .includes(); still seen in legacy code but no real advantage today
  • Give the search input a clear label — either visible or via aria-label — so its purpose is obvious before anyone starts typing
  • Announce the result count in an aria-live="polite" region so screen reader users know how many matches remain without navigating the list
  • Keep a visible "no results" message rather than letting the list silently disappear entirely
Note: An aria-live="polite" region only announces changes to its own text content, so update it every time the count changes rather than replacing the whole element — that's what triggers the announcement.

This same pattern — read the query, lowercase and compare, toggle display — is the foundation for filtering almost any list of items in the DOM, from search results to autocomplete suggestions to the table filtering covered next.

Frequently Asked Questions

How do I filter a list as you type in JavaScript?
Listen for the input event on the search box, lowercase both the query and each item's text, and hide items that do not contain the query. Use input rather than keyup so paste and autofill are caught too.
How do I make a search filter case insensitive?
Lowercase both sides before comparing, using toLowerCase() on the query and the item text. Comparing raw strings makes "Apple" fail a search for "apple", which reads as a broken search box.