Build a Search Bar with CSS Attribute Selectors and the JavaScript CSSStyleSheet API
In this post, I share a small experiment I conducted while designing the user interface for a client's application. It introduces an alternative approach to building a search bar for filtering a list of items using JavaScript. Rather than toggling the state or visibility of each item individually, I demonstrate how to filter items by dynamically modifying the CSS stylesheet using attribute selectors — a technique that's more declarative than traditional filtering methods. This is achieved using the CSSStyleSheet
API. I'll also compare this approach to the more common DOM-based method, which involves directly updating the properties of each item depending on whether it matches the search input. The concepts explained in this post are illustrated through a simple project, with a live demo shown below. You can try searching for a specific language directly here, or visit the full example page here CSS Attribute Selector Filter Example . The source code is available on GitHub sangafabrice/css-attribute-selector-filter .
1. The data
-Prefixed Attributes
The filtering technique starts with assigning attributes to each list item. In this example, I use a data
-prefixed attribute — specifically data-value
— to store the name of each language. This makes the values accessible via CSS attribute selectors, enabling filtering purely through selector logic.
In the main.js
source file, each language item is created with a data-value
attribute like so:
main.js
View on GitHub
const item = document.createElement("li");
item.dataset.value = language;
Alternatively, the same can be done using the traditional setAttribute
method:
item.setAttribute("data-value", language);
With this setup in place, you can easily query a specific language item using the browser's Developer Tools console:
document.querySelector('li[data-value="basaa"]')
<li data-value="basaa"></li>
Since each language is unique, querySelector
is sufficient to retrieve a single item. For confirmation, you can count how many elements match a selector using querySelectorAll
:
document.querySelectorAll('li[data-value="basaa"]').length
1
But if the value doesn't match exactly — such as by omitting the last character — no items will be returned:
document.querySelectorAll('li[data-value="basaa"]').length
0
To support partial matches (and emulate search behavior), you can use the *=
operator in the selector:
document.querySelector('li[data-value*="basa"]')
<li data-value="basaa"></li>
You can even find all languages containing specific patterns, such as two adjacent a
characters:
document.querySelectorAll('li[data-value*="aa"]').forEach(li => console.log(li))
<li data-value="afrikaans"></li>
<li data-value="basaa"></li>
<li data-value="kalaallisut"></li>
And to retrieve the full list of items (all 232 languages in this case), use the presence selector without any value comparison:
document.querySelectorAll('li[data-value]').length
232
Note that attempting to filter with an empty string like data-value*=""
will return no elements:
document.querySelectorAll('li[data-value*=""]').length
0
With the observations made so far, one might be tempted to implement filtering by explicitly hiding all list items that don't match the attribute selector. This can be done using the negated selector li:not([data-value*="..."])
, since the two selectors complement each other.
For instance, this expression confirms that the matched and unmatched items together cover the full list:
document.querySelectorAll('li[data-value*="aa"]').length +
document.querySelectorAll('li:not([data-value*="aa"])').length
232
A basic implementation using this logic could look like this:
<style>li[data-value] { display: flex; }</style>
<script>
document.querySelector("input").addEventListener("input", function () {
const searchText = this.value.toLowerCase();
if (searchText.length) {
document.querySelectorAll(`li[data-value*="${searchText}"]`)
.forEach(li => li.style.removeProperty("display"));
document.querySelectorAll(`li:not([data-value*="${searchText}"])`)
.forEach(li => li.style.display = "none");
return;
}
// If the input is empty, show all items
document.querySelectorAll('li[data-value]')
.forEach(li => li.style.removeProperty("display"));
});
</script>
While this solution works, it's not optimal. It queries the DOM twice for every input change, directly updates the style
property of multiple elements, and mixes logic with presentation by modifying inline styles. This not only impacts performance but also goes against the declarative approach we aim for in this experiment.
Fortunately, this is not the solution I prepared for this post. Instead, by using the CSSStyleSheet
API — as we'll explore next — we can achieve the same result without modifying the DOM or toggling individual display properties. What we can take away from this approach, however, is that it provides a clear and intuitive mental model: reasoning in terms of selectors makes the logic easy to follow and sets the stage for a cleaner, more efficient implementation.
2. The CSS Rule and The Selector Text

Now let's shift our focus from the console to the stylesheets loaded by the page. In the video above, I manually simulate the dynamic behavior that our final source code will implement by modifying the selector text of the CSS rule that uses an attribute selector
To control the visibility of the list items, we must first define how they appear in their visible (found) and hidden (not found) states. In the style.css
source file, all list items are initially hidden by default:
style.css
View on GitHub
li {
display: none;
}
To reveal items conditionally, I define a CSS rule that uses an attribute selector to target elements with the data-value
attribute:
li[data-value] {
display: flex;
}
As explained in the previous section, this rule makes all items with a data-value
attribute visible, regardless of their actual value. It serves as the default visible state when the search input is empty. This selector is more specific than the basic li
selector — its specificity is 0-1-1
compared to 0-0-1
— which ensures that the rule takes precedence and all languages are displayed when no input is provided. We can take advantage of this higher specificity by dynamically updating the selector based on the user's input. Importantly, the specificity remains the same even when using the *=
operator to match attribute values partially like:
li[data-value*="aa"] {
display: flex;
}
What's powerful about this method is that we don't need to touch the list items themselves — they remain static in the DOM. We're simply changing how they're selected and displayed through a dynamic stylesheet rule. This is a significant improvement over previous approaches that required modifying each element's style property or toggling visibility manually.
Another important point: we don't need to overwrite the entire CSS rule. Only the selector text needs to change to reflect the search input. The display property and the rule definition stay intact.
Before jumping into the solution that uses the CSSStyleSheet
API, we'll first demonstrate how to achieve this behavior using the DOM API — and explore its limitations. Since the DOM only allows us to manipulate stylesheets that exist within the document, we'll insert the default visible state CSS rule into an embedded stylesheet defined with a <style>
tag.
const style = document.createElement("style");
style.id = "generated-style";
document.querySelector("head").appendChild(style);
window["generated-style"].innerText = "li[data-value] { display : flex; }";
This inserts the following <style>
element into the document's <head>
:
<style id="generated-style">li[data-value] { display : flex; }</style>
The identifier (id
) is optional. However, third-party libraries and scripts — like Font Awesome — can also inject their own stylesheets, making it harder to identify our specific style element.
A basic implementation for the browser using the DOM API could look like this:
<script>
document.querySelector("input").oninput = function () {
const searchText = this.value.toLowerCase();
window["generated-style"].innerText = `li[data-value${
searchText.length ? `*="${searchText}"`:""
}] { display: flex }`;
};
</script>

The demo video above shows, on the right, how the embedded stylesheet updates in real time as the user enters input on the left. You may have noticed that the semicolon inside the curly brackets disappears after the first update. This isn't an inherent behavior of the DOM API itself, but rather the result of how the rule was rewritten in the demo: I intentionally left out the semicolon to clearly demonstrate that the CSS rule was being fully overwritten.
This highlights a key limitation of using the DOM API for dynamic styling. Because it doesn't offer a direct way to update just the selector text, any change — no matter how small — requires rewriting the entire rule. This can lead to unintended side effects, especially when multiple rules are affected or accidentally overwritten due to imprecise handling. In larger embedded stylesheets, this becomes error-prone and harder to maintain, as a simple selector change might inadvertently override multiple declarations.
By contrast, the CSSStyleSheet
API offers more precise control, as we'll see next.
3. Using the CSSStyleSheet
API
The CSSStyleSheet
API allows for fine-grained control over individual parts of a CSS rule — such as the selector text — making it a safer and more maintainable solution for dynamic styling. It even supports inserting rules into external stylesheets. The various ways to obtain a CSSStyleSheet
object are documented on the MDN page.
In this project, however, I prefer to keep dynamic behavior separate from static styling by isolating the rule I intend to modify inside an embedded stylesheet. This approach makes the logic easier to manage and reason about. After adding an empty <style>
element—either manually or dynamically — with the same id
used in the previous section, we can retrieve the associated CSSStyleSheet
object via the sheet
property. We then insert our first and only CSS rule, which controls the visible state of list items. This rule is inserted at index 0
, and we store a reference to it for future updates:
const genSheet = document.getElementById("generated-style").sheet;
genSheet.insertRule("li[data-value] { display: flex; }", 0);
const visibleStateCssRule = genSheet.cssRules[0];
The inserted rule won't be visible in the DOM itself, and the <style> element remains empty since its innerText
property returns an empty string:
document.getElementById("generated-style")
<style id="generated-style"></style>
document.getElementById("generated-style").innerText.length
0
However, as demonstrated in the video below, inspecting the computed styles of a visible list item reveals that the rule is indeed active and sourced from the embedded stylesheet.

For a dynamically created stylesheet, the reference shown in the Styles panel of the Developer Tools appears simply as <style>
, and it links directly to the corresponding <style>
element in the Elements panel. However, when the <style>
element is added manually in the HTML source, the reference includes the name of the page file and the line number—typically displayed as index.html:10
, or (index):10
if it's the default document served without an explicit filename.
The visibleStateCssRule
object exposes a property called selectorText
, which we can now modify dynamically to filter list items based on the user's input.
The final script improves upon the earlier DOM API example from Section 2. The key difference is that this time, we update only the selector text — without touching the rest of the CSS rule — achieving a more precise and declarative implementation.
index.html
View on GitHub
<script>
document.querySelector("input").addEventListener("input", function () {
const searchText = this.value.toLowerCase();
visibleStateCssRule.selectorText = `li[data-value${
searchText.length ? `*="${searchText}"`:""
}]`;
});
</script>
Like most examples online — including this one — we perform a case-insensitive comparison by converting the search input to lowercase. Accordingly, all data-value
attributes in the source are also lowercase.
4. Comparison with the Traditional Filtering Technique
One practical use case for this technique is filtering a list of language items that may be dynamically pulled while the user is typing. With this approach, the task becomes simpler: instead of manually toggling the visibility of items using the DOM API, we delegate that responsibility to the browser by updating a single CSS selector. Matching items remain visible, while non-matching ones are automatically hidden—without touching the DOM.
This contrasts with the traditional method, where each list item’s state is toggled individually via JavaScript. In the video below, I demonstrate how handling dynamically added items becomes more cumbersome with that approach. For this scenario, I implemented a minimal search event handler inspired by Web Dev Simplified, and you can view my adapted version in the GitHub repository.
The main difference between my version and the original is that I re-query all list items every time the input handler runs. This ensures that newly added (or pulled) items are included in the filtering logic on the next keystroke.
For illustration purposes, I added background colors to the language option cards to make the explanation clearer and easier to follow. This styling is implemented in local code snippets saved in my browser and is not available in the main or common branches of the repository. However, I've published it in a separate orphaned branch, which you can access here.
As shown in the video, when using a dynamic CSS selector for filtering, no extra steps are required to handle newly injected items — they're immediately accounted for by the updated CSS rule, making this a more scalable solution.
5. Limitation
A key limitation of this method is that CSS cannot directly target an element's text node. As a result, the searchable text must be explicitly stored as an attribute value — something that might feel redundant or counterintuitive, especially when the same text is already visible inside the element. However, for simple structured data, such as a list of languages in a translation task, this kind of duplication is a reasonable trade-off in exchange for a cleaner, more declarative filtering approach.
To display a capitalized version of the language name in the UI, instead of repeating the same value both as an attribute and as inner text, we style it using the ::after
pseudo-element. This way, the language name is drawn directly from the data-value
attribute:
style.css
View on GitHub
li::after {
content: attr(data-value);
text-transform: capitalize;
}
6. Conclusion
This project demonstrates a lightweight, declarative way to build a search filter by combining CSS attribute selectors with JavaScript's CSSStyleSheet
API. By updating only the selector text, we avoid direct DOM manipulation and inline style toggling. This results in a cleaner, more maintainable, and performant solution.
Although it may not scale well for large or complex datasets, it's a clever pattern worth exploring. I hope this experiment inspires you to rethink how you approach interactivity in the browser, and maybe even look for more opportunities to let CSS do some of the heavy lifting.
Feel free to explore the demo and check out the source code on GitHub to try it yourself.
Comments
Post a Comment