Skip to content

Everything for WordPress, web development — and beyond

🔍 jQuery: how to find an element with specific text

🔍 jQuery: how to find an element with specific text

A developer is creating a product card layout in WooCommerce. Everything goes according to plan until a task arrives from the admin panel: "remove the Featured badge and the comma after it, but only for products inside the holder category." Manual processing is not an option, there are hundreds of products. We need a script that will find the block with the text "featured" on its own, reach the parent, grab the neighboring span, and remove the entire construction.

And this is where jQuery enters the scene with its chains: .each() for iteration, .find() for searching, :contains() for filtering by text, .closest() for climbing to the parent, .next() for moving to the neighbor, and .remove() for cleanup. Five methods, and the task is solved in four lines. Below I break down each step: from the selector to the final cleanup, explaining the logic and pitfalls.

💡 Quick overview:

  • First we find the target block with text through the contains selector and climb to its parent through closest
  • Then we move to the neighboring element through next and precisely remove both blocks through remove
  • At the end we debug the script and analyze typical errors with context loss and greedy next

Task: what we're looking for and what we're removing

The original HTML structure looks roughly like this:

1<span class="tg-cats-holder">
2 <a class="product_visibility" href="...">
3 <span class="tg-item-term">featured</span>
4 </a>
5 <span>, </span>
6 <a class="product_visibility" href="...">
7 <span class="tg-item-term">sale</span>
8 </a>
9 <span>, </span>
10</span>

The key goal is to find <span class="tg-item-term"> that contains the text featured, climb to the parent <a class="product_visibility">, then move to the next <span> (with the comma) and remove it. After that, remove the <a class="product_visibility"> with the badge itself. Other labels (sale, etc.) remain untouched.

Ready script

1jQuery(document).ready(function($) {
2 $('span.tg-cats-holder').each(function() {
3 $(this)
4 .find('span.tg-item-term:contains("featured")')
5 .closest('a.product_visibility')
6 .next('span:contains(",")')
7 .remove();
8
9 $(this)
10 .find('span.tg-item-term:contains("featured")')
11 .closest('a.product_visibility')
12 .remove();
13 });
14});

The code is deliberately split into two chains rather than collapsed into one. The reason is simple: after .remove() of an element, the chain breaks, there's nowhere to go further. So first we clean the neighboring span with the comma (first chain), then remove the block itself (second).

Step-by-step breakdown

Step 1: enter each holder

1$('span.tg-cats-holder').each(function() {

The .each() method goes through each element in the collection, in our case through all <span class="tg-cats-holder"> on the page. If there are multiple holders (different product categories), the script will work in each one independently. Inside the callback function, this points to the current holder, all further searching goes from it.

Step 2: search for the block with text through:contains()

1.find('span.tg-item-term:contains("featured")')

The :contains("featured") selector finds an element inside which (including child nodes) there is the substring "featured." Three important nuances:

  • Case sensitivity. The strings "Featured," "FEATURED," and "featured" are different. If the badge is written differently in the admin panel, add a check through .filter() with .toLowerCase(), example in the FAQ section.

  • Search through all descendants. :contains() checks the text content of both the element itself and all its children. If there's another <strong> inside <span class="tg-item-term">, the text inside it counts too.

  • This is not a performance selector. :contains() is not accelerated by native querySelectorAll, jQuery goes through all candidates and reads .textContent. For pages with hundreds of elements, it's better to narrow the selection with an additional class or attribute.

The .find() method limits the search to descendants of the current holder, not the entire page, this provides both a speed boost and protection from false positives.

Step 3: climb to the parent through.closest()

1.closest('a.product_visibility')

.closest() goes up the tree from the found element and returns the closest ancestor matching the selector. Unlike .parent(), it's not limited to one level, it will climb all the way to <html> until it finds a match or hits the root.

If we had taken .parent() instead of .closest(), the script would break with any nesting: <span class="tg-item-term"><strong><a class="product_visibility">, .parent() would return <strong>, not the needed link. .closest() insures against such surprises.

Step 4: jump to the neighbor through.next()

1.next('span:contains(",")')

.next() takes the immediately following sibling element of the same parent. The span:contains(",") filter guarantees that we take exactly the span with the comma, not any following element. Without the filter, .next() would grab the first available sibling node, for example, another <a class="product_visibility"> with the next label.

The difference between .next() and .nextAll(): the first takes exactly one element, the second takes all subsequent siblings. Here we need a precise strike, so .next().

Step 5: remove

1.remove();

.remove() cuts the element from the DOM completely, along with event handlers and jQuery data. If you only need to hide (with the prospect of returning), use .hide() or .detach(). But for the task "remove the badge forever," only .remove().

The order matters: first we remove the neighboring span (first chain), only then the block itself (second). If we remove the block first, the neighboring span will lose context, .next() will work from a different element or return an empty collection.

In a real project, the label text may come with different case: featured, Featured, FEATURED. Standard :contains() won't handle this. The solution is a custom selector based on .filter():

1$('span.tg-cats-holder').each(function() {
2 $(this).find('span.tg-item-term').filter(function() {
3 return $(this).text().toLowerCase().indexOf('featured') !== -1;
4 }).closest('a.product_visibility').each(function() {
5 $(this).next('span:contains(",")').remove();
6 $(this).remove();
7 });
8});

Here .filter() with a callback function checks the lowercase text of each candidate. There are slightly more lines, but the script doesn't depend on exactly how the text was entered in the admin panel.

Alternative in pure JavaScript

If jQuery is not used on the project at all, the same logic is written in vanilla JS:

1document.querySelectorAll('span.tg-cats-holder').forEach(function(holder) {
2 holder.querySelectorAll('span.tg-item-term').forEach(function(term) {
3 if (term.textContent.includes('featured')) {
4 const link = term.closest('a.product_visibility');
5 const nextSpan = link.nextElementSibling;
6 if (nextSpan && nextSpan.matches('span') && nextSpan.textContent.includes(',')) {
7 nextSpan.remove();
8 }
9 link.remove();
10 }
11 });
12});

The same algorithm: iterating holders → searching by text through .textContent.includes() → climbing to parent through native .closest() → checking and removing neighbor → removing block. The native implementation is longer but doesn't pull in a jQuery dependency.

Short video on the topic, breakdown of :contains() and navigation chains in jQuery:

⁉️🤔 Frequently asked questions

Why doesn't :contains() find text that I see on the page?

Most likely the problem is in case or spaces. :contains("Featured") won't find "featured," this is a case-sensitive selector. Plus the browser may normalize spaces inside HTML not as you expect. Check the exact content through console.log($('selector').text()) and copy the string one-to-one into :contains().

Can we do without .closest() and use a fixed chain .parent().parent()?

Technically, yes. Practically, not recommended. A hard chain .parent().parent() will break with any change in nesting: added a wrapper, moved to a different theme, the script stopped. .closest('a.product_visibility') is more flexible: it searches for the closest matching element at any level up. Written once, works regardless of structure.

The script removes the wrong span. How to debug?

First step, insert console.log() before each .remove() and check what exactly gets into the selection. Second, refine the selector for .next(). Without a filter, .next() takes any following element at the same level, even if it's another <a> or <div>. Add span:contains(",") or a more specific class, and verify that the neighbor is actually the right one.

Do I need to wrap the script in jQuery(document).ready()?

Yes, if the script is in <head> or connects before DOM render. The jQuery(document).ready(function($){ ... }) construction guarantees that the code executes only after the complete loading of the DOM tree. Plus $ is passed explicitly into the callback, this insures against conflicts with other libraries that may also claim $. If the script is in the footer after all elements, .ready() is formally not required, but the discipline of "always wrapping" saves from hard-to-catch bugs during refactoring.

What's faster: jQuery or vanilla JS for such a task?

Native JavaScript wins on a cold start: no need to load, parse, and compile jQuery, and this saves dozens of kilobytes of traffic and several milliseconds of parsing. On DOM operations, the gap is more modest, we're talking about fractions of a millisecond per call. For a typical WordPress site where jQuery is already connected by the theme or plugins, the difference is imperceptible, your four-line script won't become a bottleneck. If jQuery is already in the project, write in jQuery, the code is more compact and readable. If not, take vanilla JS, don't drag in a library for one handler.

Is it worth using jQuery for text search in 2026?

jQuery hasn't gone anywhere. According to W3Techs statistics for June 2026, the library is installed on 87.3% of sites with a known JavaScript library (68.1% of all sites), mainly thanks to WordPress, where jQuery is in the core and automatically connected by themes. For a WordPress developer, jQuery is a native tool that's already loaded and doesn't create extra requests.

Direct alternatives like Alpine.js or HTMX solve different tasks (reactivity, partial updates) and are excessive for a one-time DOM script. Native querySelectorAll + closest() + remove() is a working option without dependencies, but the call chains get longer. The choice comes down to a simple rule: jQuery is already on the project, take jQuery; project without jQuery, take vanilla JS.

The main thing is that you now know the exact mechanics of the :contains() + .closest() + .next() + .remove() combination, and which dialect to write it in is decided by the specific project.