Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

jQuery 4 (legacy code and migration to DOM APIs)  Module 2 – Selecting, reading and changing the page

jQuery selectors, extensions and native queries

Which jQuery selectors the browser refuses, how to rewrite :checkbox, :text, :contains() and :first as standard CSS, and how to escape ids such as price.total.

  • Beginner
  • 15 minutes
  • Examples run with Node.js 24.21.0, jQuery 4.0.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Distinguish jQuery-only selector extensions from standard CSS and predict which ones querySelectorAll() refuses
  • Rewrite :checkbox, :text, :input, :selected and :contains() as standard selectors or filters that find the same elements
  • Replace positional selectors such as :first, :even and :gt() with .first(), .even(), .eq() and .slice()
  • Select ids that contain dots, colons or a leading digit with $.escapeSelector(), CSS.escape() or getElementById()
  • Write custom pseudo-selectors that work in jQuery 4, or replace them with standard attribute selectors

Before you start

What you’ll learn · Before you start

What you will learn

  • Distinguish jQuery-only selector extensions from standard CSS and predict which ones querySelectorAll() refuses
  • Rewrite :checkbox, :text, :input, :selected and :contains() as standard selectors or filters that find the same elements
  • Replace positional selectors such as :first, :even and :gt() with .first(), .even(), .eq() and .slice()
  • Select ids that contain dots, colons or a leading digit with $.escapeSelector(), CSS.escape() or getElementById()
  • Write custom pseudo-selectors that work in jQuery 4, or replace them with standard attribute selectors

Before you start

On this page

$(':checkbox') finds the two checkboxes of a shop’s checkout form; document.querySelectorAll(':checkbox') on the same page throws a SyntaxError. jQuery’s documentation files 26 selectors under “jQuery Extensions”, and legacy code is full of them: each one works until the day the code moves to the browser’s own querySelectorAll().

Every example below is that checkout page, run in jsdom (a browser-like DOM for Node.js) under jQuery 4.0.0 and then 3.7.1, and its scripts log which selectors the DOM’s querySelectorAll() accepted and which ones jQuery matched. You will rewrite extensions as standard selectors or methods that find the same elements, avoid the traps in :text, :checked, :contains() and :even, escape ids such as price.total, and keep custom pseudo-selectors working on jQuery 4.

jQuery asks the browser first, and matches the rest itself

The checkout page has a form with two checkboxes, two lists of dishes and a line with the total. Its script, engine.js, puts a spy on the document’s querySelectorAll(): a wrapper that passes every call on to the real method and notes each selector the method accepted and each one it refused. Then it asks jQuery for five selectors and logs how many elements came back and which engine found them:

The checkout page and its script

checkout/engine.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Checkout</title>
  </head>
  <body>
    <h1>Checkout</h1>
    <form id="checkout">
      <input name="name" placeholder="Your name">
      <input type="text" name="city" placeholder="City">
      <label><input type="checkbox" name="gift" checked> Gift wrap</label>
      <label><input type="checkbox" name="express"> Express delivery</label>
      <select name="slot">
        <option>Morning</option>
        <option selected>Evening</option>
      </select>
      <textarea name="note"></textarea>
      <button type="submit">Place order</button>
      <input type="button" value="Clear">
    </form>
    <ul class="menu" id="meals">
      <li data-stock="0">Thali</li>
      <li data-stock="4">Biryani</li>
      <li data-stock="0">Dosa</li>
    </ul>
    <ul class="menu" id="snacks">
      <li data-stock="12">Samosa</li>
      <li data-stock="0">Vada pav</li>
    </ul>
    <p class="total">Total due: ₹420</p>
    <div id="price.total">420</div>
    <div id="order:42">Order 42</div>
    <div id="1st-visit">Welcome back</div>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="engine.js"></script>
  </body>
</html>

checkout/engine.js

// engine.js: which selectors querySelectorAll() accepted, and which ones jQuery matched with its own engine.
// A spy wraps the document's querySelectorAll() and notes every selector it accepted and every one it refused.
const accepted = new Set();
const refused = new Set();
const native = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function (selector) {
  try {
    const found = native.call(this, selector);
    accepted.add(selector);
    return found;
  } catch (error) {
    refused.add(selector);
    throw error;
  }
};

for (const selector of ['input[type=checkbox]', ':checkbox', 'ul:has(li)', 'li:first', 'p:contains(Total)']) {
  const found = $(selector).length;
  const by = refused.has(selector) ? "jQuery's own engine" : accepted.has(selector) ? 'querySelectorAll()' : 'a lookup without querySelectorAll()';
  console.log(`${selector}: ${found} found by ${by}`);
}

try {
  document.querySelectorAll(':checkbox');
} catch (error) {
  console.log(`document.querySelectorAll(':checkbox') throws ${error.name}`);
}
What engine.js logged JavaScript · engine.mjs
// Prints what checkout/engine.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1 (recorded.mjs).
import { show } from './show.mjs';

show('checkout/engine.html');

Output

input[type=checkbox]: 2 found by querySelectorAll()
:checkbox: 2 found by jQuery's own engine
ul:has(li): 2 found by querySelectorAll()
li:first: 1 found by jQuery's own engine
p:contains(Total): 1 found by jQuery's own engine
document.querySelectorAll(':checkbox') throws SyntaxError
(jQuery 3.7.1 logged the same 6 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node engine.mjs

Every one of the five selectors went to querySelectorAll() first. The DOM’s engine knows only the selectors of the CSS standard, so it answered input[type=checkbox] and ul:has(li), and threw for :checkbox, li:first and p:contains(Total). jQuery caught each error and matched the selector with its own engine: the code of the Sizzle project, which jQuery 3.7 moved into jQuery itself (3.7.0 release notes). Both versions logged the same lines. Step through one standard selector and one extension:

Interactive · stage by stage How $() finds the elements of a selector Step through one standard selector and one extension, from the call to the collection jQuery returns.
How $() finds the elements of a selector, step 6 of 6. Showing $(':checkbox'), #id, tag or .class only?, querySelectorAll() (throws for :checkbox), jQuery's own engine (not there without jQuery), A jQuery collection. In focus: querySelectorAll().yes: looked up at oncestandard CSS: accepted$(':checkbox')or any selector string#id, tag or .class only?getElementById() and kinquerySelectorAll()throws for :checkboxjQuery's own enginenot there without jQueryA jQuery collectionin document order

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A selector string. The page's script calls $() with a selector. Whatever the string holds, jQuery has to find the elements, and it tries the cheapest way first.
  2. The shortcut. A selector that is only an id, a tag name or a class goes straight to the DOM lookups that take exactly that, such as getElementById(). :checkbox is none of these.
  3. Ask the browser. Every other selector goes to querySelectorAll() first. For input[type=checkbox] the browser answers with the two checkboxes of the form.
  4. Refused. :checkbox is not CSS, so querySelectorAll() throws a SyntaxError. jQuery catches the error and notes that this selector needs its own engine.
  5. jQuery matches it. jQuery's own engine, the Sizzle code that jQuery 3.7 moved into jQuery, reads :checkbox and returns the same two checkboxes. The page sees no error.
  6. Without jQuery. Code moved to document.querySelectorAll() keeps only the browser engine. Every extension that jQuery used to match on its own now throws the SyntaxError instead.

The two engines also explain jQuery’s own advice. Queries that use an extension “cannot take advantage of the performance boost” of querySelectorAll(), so the documentation suggests selecting with standard CSS first and narrowing the result with .filter() (jQuery Extensions). The document.querySelectorAll(':checkbox') line of the output is the other consequence: code that calls document.querySelectorAll() itself has no second engine to fall back on, and every extension in it becomes an exception.

Key idea

jQuery tries a selector on the browser’s querySelectorAll() first and quietly matches extensions with its own engine; code without jQuery gets the SyntaxError instead.

Checkpoint

Check: Which engine matches a selector

  1. Question 1 of 2 On the checkout page, which of these selectors did querySelectorAll() answer itself, so that jQuery's own engine was never needed?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ul:has(li)

    jQuery hands every selector that is not a lone id, tag or class to querySelectorAll() first. Standard CSS, :has() included, comes back from the browser; each extension makes it throw, and jQuery then uses its own engine.

  2. Question 2 of 2 A script is moved from $(':checkbox') to document.querySelectorAll(':checkbox') and nothing else changes. What happens on the checkout page?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It throws a SyntaxError, because the browser engine knows no :checkbox

    An extension that jQuery matched silently becomes an exception once the code calls the DOM directly, which is why the extensions are translated before jQuery is removed.

Form and text extensions have standard rewrites, with traps

Most selector extension: A selector that jQuery understands but CSS does not, such as :checkbox, :first or :contains(). jQuery matches it with its own engine; the browser's querySelectorAll() throws a SyntaxError for it. in legacy code name kinds of form field: :checkbox, :text, :input, :button, :selected. Each has a standard equivalent, but a word-for-word swap is wrong for some of them. rewrites.js runs on the same page; it logs each extension, then its rewrite, and says whether both found the same elements:

rewrites.js JavaScript · checkout/rewrites.js
// rewrites.js: each jQuery extension next to the standard selector that replaces it, and the elements each one finds.
// A short name for each element: a form field's name, a button's text, an option's text, else its tag.
function label(el) {
  if (el.name) return el.name;
  if (el.localName === 'input') return `"${el.value}"`;
  if (el.localName === 'button' || el.localName === 'option') return `"${el.textContent.trim()}"`;
  return el.localName;
}

function compare(extension, standard) {
  const ours = $(extension).toArray().map(label);
  const theirs = [...document.querySelectorAll(standard)].map(label);
  console.log(`${extension} -> ${ours.length}: ${ours.join(', ')}`);
  const same = ours.join() === theirs.join();
  console.log(`  ${standard} -> ${theirs.length}${same ? ', the same elements' : `: ${theirs.join(', ')}`}`);
}

compare(':checkbox', 'input[type=checkbox]');
compare(':text', 'input[type=text]');
compare(':text', 'input[type=text], input:not([type])');
compare(':input', 'input, select, textarea, button');
compare(':button', 'button, input[type=button]');
compare(':header', 'h1, h2, h3, h4, h5, h6');
compare(':selected', 'option:checked');

// :checked is standard CSS, and it matches a selected option as well as a ticked box.
console.log(`:checked -> ${$(':checked').toArray().map(label).join(', ')}`);

// :contains() has no CSS equivalent: it matches every element whose text holds the words, ancestors included.
console.log(`:contains(Total) -> ${$(':contains(Total)').toArray().map(label).join(', ')}`);
const paragraphs = [...document.querySelectorAll('p')].filter((p) => p.textContent.includes('Total'));
console.log(`  p elements whose text includes Total -> ${paragraphs.map(label).join(', ')}`);
What rewrites.js logged JavaScript · rewrites.mjs
// Prints what checkout/rewrites.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1 (recorded.mjs).
import { show } from './show.mjs';

show('checkout/rewrites.html');

Output

:checkbox -> 2: gift, express
  input[type=checkbox] -> 2, the same elements
:text -> 2: name, city
  input[type=text] -> 1: city
:text -> 2: name, city
  input[type=text], input:not([type]) -> 2, the same elements
:input -> 8: name, city, gift, express, slot, note, "Place order", "Clear"
  input, select, textarea, button -> 8, the same elements
:button -> 2: "Place order", "Clear"
  button, input[type=button] -> 2, the same elements
:header -> 1: h1
  h1, h2, h3, h4, h5, h6 -> 1, the same elements
:selected -> 1: "Evening"
  option:checked -> 1, the same elements
:checked -> gift, "Evening"
:contains(Total) -> html, body, p
  p elements whose text includes Total -> p
(jQuery 3.7.1 logged the same 17 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node rewrites.mjs

jQuery extension Standard rewrite What to watch
:checkbox input[type=checkbox] Nothing: the same two boxes
:text input[type=text], input:not([type]) An input without type is a text field
:input input, select, textarea, button Every kind of form field
:button button, input[type=button] Both kinds of button
:header h1, h2, h3, h4, h5, h6 All six heading levels
:selected option:checked Selected options
:contains(text) a filter on textContent No CSS equivalent; ancestors match too
:visible, :hidden el.checkVisibility() Needs a page the browser has laid out

Three traps hide among these rewrites. The name field has no type attribute, and :text has matched such inputs since jQuery 1.5.2, because their type is text by default (:text), so input[type=text] found one field where :text found two. :checked is not an extension at all: it is CSS, and it matches a selected option as well as a ticked box, which is why $(':checked') returned the gift-wrap box and the Evening option. To count ticked boxes, write input:checked.

Matching every element that holds the words

Wrong: $(':contains(Total)') to find the total line. It matched html, body and the paragraph, because the text of every ancestor includes the words; it is also case-sensitive (:contains()). Right: name the element, p:contains(Total), and in code without jQuery filter a standard selection by its text, as the last line of rewrites.js does.

:visible needs a word of its own. Since jQuery 3, an element is :visible when it has a layout box, even one of zero width and height, and elements with visibility: hidden or opacity: 0 count as visible because they still take up space (:visible). jQuery’s definition is one line of its source, !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length) (hiddenVisibleSelectors.js). The closest native test is el.checkVisibility(), which returns false for an element without a box, such as one with display: none (MDN). jsdom lays nothing out, so the page does not run :visible: there, every element would count as hidden. Try it in a real browser with the playground at the end of this lesson.

Checkpoint

Check: Standard rewrites of extensions

  1. Question 1 of 2 Which standard selector finds exactly the elements that :text finds on the checkout page?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: input[type=text], input:not([type])

    An input whose type is missing is a text field, and jQuery's :text has matched it since 1.5.2. A faithful rewrite therefore adds input:not([type]) to input[type=text].

  2. Question 2 of 2 How many elements does $(':checked') find on the checkout page, with its ticked gift-wrap box and its selected Evening option?

    Type a number.

    A hint

    :checked is a standard pseudo-class. Which elements does CSS let it match?

    Show the answer: Question 2 of 2

    Answer: 2 elements

    :checked matches ticked checkboxes and radio buttons and also selected options, so the run found the gift-wrap box and the Evening option. To count ticked boxes, write input:checked.

Positional selectors count across the whole set

:first, :last, :eq(), :even, :odd, :lt() and :gt() are positional selector: A jQuery selector extension that picks elements by their position in the whole matched set, counted from 0: :first, :last, :eq(), :even, :odd, :lt() and :gt(). They are deprecated since jQuery 3.4 in favour of methods such as .first() and .eq().: they pick elements by their position in everything the selector found, counted from 0 across the whole page. CSS’s :first-child and :nth-child() count from 1 inside each parent. The two kinds look alike in code and part ways as soon as a page has more than one list, as the checkout page has: three meals and two snacks. Step through the selectors on the five items:

Interactive · step by step Positions in the set, and positions inside each list Step through each selector and watch which menu items it picks. The sub-labels give each item's position in the set of all five.
Positions in the set, and positions inside each list, step 7 of 7. Showing li: Thali, li: Biryani, li: Dosa, li: Samosa, li: Vada pav. In focus: li: Samosa, li: Vada pav.li: Thaliposition 0li: Biryaniposition 1li: Dosaposition 2li: Samosaposition 3li: Vada pavposition 4

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. One set of five. $('li') finds the five items in document order: the three meals, then the two snacks. Positional selectors and methods count positions 0 to 4 across this whole set.
  2. li:first. :first keeps one element for the whole page, the one at position 0: Thali. .first() returns the same item.
  3. li:first-child. CSS counts inside each parent instead: :first-child matches the first child of each list, Thali and Samosa.
  4. li:even. :even keeps positions 0, 2 and 4 of the set: Thali, Dosa and Vada pav, wherever they sit on the page.
  5. li:nth-child(odd). :nth-child(odd) counts from 1 and starts again in each list: the 1st and 3rd meal and the 1st snack, so Samosa takes the place of Vada pav.
  6. .even(). The method .even() numbers the set as :even does and returns Thali, Dosa and Vada pav, with no extension in the selector.
  7. li:gt(2) and .slice(3). :gt(2) keeps the positions above 2, Samosa and Vada pav. .slice(3) starts at position 3 and returns the same two items.

The page’s script runs the same selectors, each next to its CSS look-alike or its method:

positions.js JavaScript · checkout/positions.js
// positions.js: positional selectors count across every element the selector found; CSS counts inside each parent.
const texts = ($set) => $set.toArray().map((li) => li.textContent).join(', ') || 'nothing';
const show = (what, $set) => console.log(`${what} -> ${texts($set)}`);

show('li:first', $('li:first'));
show('li:first-child', $('li:first-child'));
show(".first() of $('li')", $('li').first());
show('li:last', $('li:last'));
show('li:last-child', $('li:last-child'));
show('li:even', $('li:even'));
show('li:nth-child(odd)', $('li:nth-child(odd)'));
show(".even() of $('li')", $('li').even());
show('li:eq(1)', $('li:eq(1)'));
show(".eq(1) of $('li')", $('li').eq(1));
show('li:gt(2)', $('li:gt(2)'));
show(".slice(3) of $('li')", $('li').slice(3));
What positions.js logged, without and with jQuery Migrate JavaScript · positions.mjs
// Prints what checkout/positions.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1, and what jQuery Migrate 4.0.2
// added when the same page loaded it (recorded.mjs).
import { show, showAgainst } from './show.mjs';

show('checkout/positions.html');
console.log('With jQuery Migrate 4.0.2 on the page:');
showAgainst('checkout/positions-migrate.html', 'checkout/positions.html');

Output

li:first -> Thali
li:first-child -> Thali, Samosa
.first() of $('li') -> Thali
li:last -> Vada pav
li:last-child -> Dosa, Vada pav
li:even -> Thali, Dosa, Vada pav
li:nth-child(odd) -> Thali, Dosa, Samosa
.even() of $('li') -> Thali, Dosa, Vada pav
li:eq(1) -> Biryani
.eq(1) of $('li') -> Biryani
li:gt(2) -> Samosa, Vada pav
.slice(3) of $('li') -> Samosa, Vada pav
(jQuery 3.7.1 logged the same 12 lines)
With jQuery Migrate 4.0.2 on the page:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
(and the same 12 lines as before)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node positions.mjs

li:even returned Thali, Dosa and Vada pav, the positions 0, 2 and 4 of the set, and no CSS selector gives that: :nth-child(odd) starts counting again in the snacks list and picks Samosa instead. The documentation itself warns that the count from 0 means, counter-intuitively, that :even selects the first, third and fifth element (:even).

jQuery 3.4 deprecated these selectors because “anything you can do with positional selectors, you can do with positional methods instead” (3.4.0 release notes):

Positional selector Method on the set
li:first, li:last $('li').first(), $('li').last()
li:eq(n) $('li').eq(n)
li:even, li:odd $('li').even(), $('li').odd() (jQuery 3.5 and later)
li:lt(n) $('li').slice(0, n)
li:gt(n) $('li').slice(n + 1)

On 4.0.0 the selectors still work, and jQuery Migrate 4.0.2 logged nothing about them, only its start-up line. Migrate will not find them for you; the code has to be searched, which is what this lesson’s exercise does.

Key idea

Positional selectors number the whole set from 0; move each one to the method of the same name, and :lt() and :gt() to .slice().

Checkpoint

Check: Positional selectors and methods

  1. Question 1 of 2 Which items does $('li:even') return from the checkout page's two menus, meals (Thali, Biryani, Dosa) and snacks (Samosa, Vada pav)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Thali, Dosa and Vada pav

    Positional selectors number everything the selector found from 0, across all lists. .even() returns the same three items without an extension.

  2. Question 2 of 2 You need the first item of each menu on the checkout page. Which selector gives you that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: li:first-child

    :first, :eq() and .first() look at the set as one list. When you mean one per parent, the CSS structural pseudo-classes :first-child and :nth-child() are the right tool.

An id with a dot or a colon must be escaped

Ids made by templates and form builders often hold characters that mean something in CSS: price.total, order:42, item[3]. Written into a selector, #price.total means an element whose id is price and whose class is total. escape.js selects the page’s two such ids, and one that begins with a digit, in three ways:

escape.js JavaScript · checkout/escape.js
// escape.js: ids with characters that mean something in CSS, selected three ways.
const count = (what, n) => console.log(`${what} -> ${n} found`);

count("$('#price.total')", $('#price.total').length);
const price = $.escapeSelector('price.total');
console.log(`$.escapeSelector('price.total') -> ${price}`);
count(`$('#${price}')`, $(`#${price}`).length);
count(`document.querySelectorAll('#${price}')`, document.querySelectorAll(`#${price}`).length);
count("document.getElementById('price.total')", document.getElementById('price.total') ? 1 : 0);

const order = $.escapeSelector('order:42');
console.log(`$.escapeSelector('order:42') -> ${order}`);
count(`$('#${order}')`, $(`#${order}`).length);

// An id that starts with a digit is not a valid CSS identifier.
count("$('#1st-visit')", $('#1st-visit').length);
try {
  document.querySelectorAll('#1st-visit');
} catch (error) {
  console.log(`document.querySelectorAll('#1st-visit') throws ${error.name}`);
}
count("document.getElementById('1st-visit')", document.getElementById('1st-visit') ? 1 : 0);
console.log(`$.escapeSelector('1st-visit') -> ${$.escapeSelector('1st-visit')}`);
What escape.js logged JavaScript · escape.mjs
// Prints what checkout/escape.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1 (recorded.mjs).
import { show } from './show.mjs';

show('checkout/escape.html');

Output

$('#price.total') -> 0 found
$.escapeSelector('price.total') -> price\.total
$('#price\.total') -> 1 found
document.querySelectorAll('#price\.total') -> 1 found
document.getElementById('price.total') -> 1 found
$.escapeSelector('order:42') -> order\:42
$('#order\:42') -> 1 found
$('#1st-visit') -> 1 found
document.querySelectorAll('#1st-visit') throws SyntaxError
document.getElementById('1st-visit') -> 1 found
$.escapeSelector('1st-visit') -> \31 st-visit
(jQuery 3.7.1 logged the same 11 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node escape.mjs

$('#price.total') found nothing. $.escapeSelector(), added in jQuery 3.0, puts a backslash before every character with a special meaning in CSS, and the escaped selector found the element both in jQuery and in querySelectorAll(). Browsers have the same function, CSS.escape(): jQuery’s documentation calls $.escapeSelector() “essentially a shim” for it, and MDN’s example turns .foo#bar into \.foo\#bar (CSS.escape()). jsdom has no CSS.escape(), so the page shows jQuery’s.

An id that begins with a digit is not a valid name in CSS, so document.querySelectorAll('#1st-visit') threw a SyntaxError. $('#1st-visit') found the element anyway, because a selector that is a lone id takes jQuery’s shortcut through getElementById(), the first branch of the figure in the first section: the code works with jQuery and breaks without it. Escaping writes the digit as its code point, \31 .

When the selector is only the id

Skip escaping and write document.getElementById('price.total'): it compares the id as plain text. Use CSS.escape(id) when the id is part of a longer selector, such as `#${CSS.escape(id)} > img`.

Checkpoint

Check: Ids with special characters

  1. Question 1 of 2 What does $('#price.total') look for?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An element whose id is price and whose class list holds total

    A character with a meaning in CSS has to be escaped when an id is written into a selector. $.escapeSelector() and CSS.escape() do it; getElementById() needs no escaping at all.

  2. Question 2 of 2 The checkout page has <div id="1st-visit">. Which of these calls return that element?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • document.getElementById('1st-visit')
    • $('#1st-visit')

    jQuery's shortcut for a lone #id hides the problem until the code moves to querySelector(). There, write getElementById(), or escape the id: $.escapeSelector('1st-visit') gives \31 st-visit.

Custom pseudo-selectors need createPseudo() in jQuery 4

Plugins and older application code define selectors of their own, such as :soldout, by adding a function to jQuery’s selector engine. jQuery 4 keeps two of the ways legacy code does it and drops the third, without an error. The menu items of the checkout page carry their stock in a data-stock attribute, and pseudos.js defines a pseudo-class: The part of a selector that starts with a colon and tests a state or a position of an element, such as :checked or :first-child in CSS. jQuery adds pseudo-classes of its own and lets code define more on jQuery.expr.pseudos. each way:

pseudos.js JavaScript · checkout/pseudos.js
// pseudos.js: custom pseudo-selectors, defined the three ways legacy code does it, and a standard rewrite.
const texts = (list) => [...list].map((li) => li.textContent).join(', ') || 'nothing';

// 1. A function on jQuery.expr.pseudos.
jQuery.expr.pseudos.soldout = (el) => el.dataset.stock === '0';
console.log(`li:soldout -> ${texts($('li:soldout'))}`);

// 2. The same, through the old name jQuery.expr[':'].
jQuery.expr[':'].instock = (el) => el.dataset.stock !== '0';
console.log(`li:instock -> ${texts($('li:instock'))}`);

// 3. An argument read from match[3], the way pseudos with an argument were once written.
jQuery.expr.pseudos.stock = function (el, i, match) {
  return el.dataset.stock === match[3];
};
console.log(`li:stock(4) -> ${texts($('li:stock(4)'))}`);

// The supported way to take an argument: createPseudo().
jQuery.expr.pseudos.stockof = jQuery.expr.createPseudo((count) => (el) => el.dataset.stock === count);
console.log(`li:stockof(4) -> ${texts($('li:stockof(4)'))}`);

// No extension needed: a standard attribute selector.
console.log(`li[data-stock="4"] -> ${texts(document.querySelectorAll('li[data-stock="4"]'))}`);
What pseudos.js logged, without and with jQuery Migrate JavaScript · pseudos.mjs
// Prints what checkout/pseudos.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1, and what changed when the same
// page loaded jQuery Migrate 4.0.2 (recorded.mjs).
import { show, showAgainst } from './show.mjs';

show('checkout/pseudos.html');
console.log('With jQuery Migrate 4.0.2 on the page:');
showAgainst('checkout/pseudos-migrate.html', 'checkout/pseudos.html');

Output

li:soldout -> Thali, Dosa, Vada pav
li:instock -> Biryani, Samosa
li:stock(4) -> nothing
li:stockof(4) -> Biryani
li[data-stock="4"] -> Biryani
(jQuery 3.7.1 logged other lines:)
  line 3: li:stock(4) -> Biryani
With jQuery Migrate 4.0.2 on the page:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
(info) JQMIGRATE: jQuery.expr[':'] is deprecated; use jQuery.expr.pseudos
(info) JQMIGRATE: Pseudos with multiple arguments are deprecated; use jQuery.expr.createPseudo()
li:stock(4) -> Biryani
(and the same 4 lines as before)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pseudos.mjs

li:soldout and li:instock worked on both versions: a function on jQuery.expr.pseudos, or the same through its old name jQuery.expr[':'], which jQuery Migrate reports as deprecated (warnings.md). li:stock(4) is the silent break. Its function reads the argument from match[3], the way custom pseudo-selectors with an argument were once written; jQuery 4 dropped this “legacy custom pseudos” form (4.0 upgrade guide), so match[3] no longer holds the 4, the test is false for every item, and the selector found nothing on 4.0.0 where 3.7.1 found Biryani. Migrate 4.0.2 brought the old behaviour back and logged “Pseudos with multiple arguments are deprecated”.

The fix is jQuery.expr.createPseudo(), which receives the argument once and returns the test; li:stockof(4) found Biryani on both versions. Often no extension is needed at all: the standard li[data-stock="4"] found the same item, and it keeps working once jQuery is gone. These are the releases that shaped the selectors you will meet in old code:

Timeline · 7 events in order The jQuery releases that changed selectors Walk through the releases in order to date the selector features you find in old code.
  1. 1.0

    Extensions from the start extensions

    The first jQuery already matched selectors that CSS lacks, such as :even and :input, next to the standard ones.

  2. 1.5.2

    :text without a type changed match

    :text started to match input elements with no type attribute, whose type is text by default.

  3. 3.0

    escapeSelector() and :visible new method

    $.escapeSelector() arrived, and :visible started to count every element with a layout box, even one of zero width and height.

  4. 3.4

    Positional selectors deprecated deprecated

    :first, :last, :eq, :even, :odd, :lt, :gt and :nth were deprecated in favour of the methods .first(), .last() and .eq().

  5. 3.5

    .even() and .odd() new method

    The methods .even() and .odd() gave :even and :odd a method to move to.

  6. 3.7

    Sizzle moves into jQuery engine

    The selector engine Sizzle, until then a separate project, became part of jQuery's own code, to prepare for changes to selection in later versions.

  7. 4.0

    Legacy custom pseudos dropped removed

    Custom pseudo-selectors that read their argument from a match array stopped working; jQuery.expr.createPseudo() takes their place.

The timeline has 7 events, from 1.0 to 4.0.

Checkpoint

Check: Custom pseudo-selectors in jQuery 4

  1. One question This pseudo-selector worked on jQuery 3.7.1. What does the last line log on jQuery 4.0.0, on the checkout page where only Biryani has a stock of 4?

    Read the code, then choose one answer.

    jQuery.expr.pseudos.stock = function (el, i, match) {
      return el.dataset.stock === match[3];
    };
    console.log($('li:stock(4)').length);
    Show the answer: One question

    Answer: 0, and no error

    jQuery 4 dropped the legacy signature of custom pseudo-selectors. jQuery.expr.createPseudo() receives the argument and returns the test, and it works on both versions.

Practise: find the extensions in a codebase

Migrate is silent about positional selectors, so an audit before a migration searches the selector strings themselves. Write the function that does it for one string; the sample tests run in your browser:

Exercise · Medium · JavaScript

Find the jQuery-only selectors in a selector string

Before a page can move from $() to document.querySelectorAll(), every selector string in its code has to be free of jQuery's extensions, and jQuery Migrate does not report all of them. Write findExtensions(selector) in extensions.mjs: it returns the extensions one selector string uses, so that a script can list them for a whole codebase.

  • Report each pseudo-class of this list, written with its colon and without any argument: :animated, :button, :checkbox, :contains, :eq, :even, :file, :first, :gt, :header, :hidden, :image, :input, :last, :lt, :odd, :parent, :password, :radio, :reset, :selected, :submit, :text and :visible. :has() is standard CSS now and is not reported.
  • Report the attribute selector [name!="value"], which CSS does not have either, as [!=].
  • Report each extension once, in the order it first appears in the string.
  • Standard pseudo-classes are not extensions, even when their names begin the same way: :checked, :first-child, :last-of-type and :nth-child(2) are CSS.
  • Text inside quotes and inside an attribute selector is not a selector: a[href$=":first"] and p:contains("Total: 420") use no :first and no :420, and a[title="x!=y"] is not [!=].
  • A backslash escapes the character after it, as in the escaped ids of the lesson: #step\:first is the element whose id is step:first, so it uses no :first.

For example, findExtensions('#checkout :checkbox:checked, li:first') returns [':checkbox', ':first'].

Starter code · extensions.mjs

/**
 * The jQuery selector extensions a selector string uses, each once, in the order they first appear: ':checkbox',
 * ':first', ':contains' (without its argument), or '[!=]' for an attribute-not-equal selector.
 * @param {string} selector
 * @returns {string[]}
 */
export function findExtensions(selector) {
  return [];
}
The sample tests · extensions.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { findExtensions } from './extensions.mjs';

test('standard selectors use no extension', () => {
  assert.deepEqual(findExtensions('input[type=checkbox]:checked'), []);
  assert.deepEqual(findExtensions('ul > li:nth-child(2), li:first-child, li:last-of-type'), []);
  assert.deepEqual(findExtensions('ul:has(li.sale)'), []);
});

test('form and positional extensions are reported without their argument', () => {
  assert.deepEqual(findExtensions('#checkout :checkbox:checked, li:first'), [':checkbox', ':first']);
  assert.deepEqual(findExtensions('li:eq(2), tr:even'), [':eq', ':even']);
  assert.deepEqual(findExtensions('li:not(:first):visible'), [':first', ':visible']);
});

test('each extension is reported once, in the order it first appears', () => {
  assert.deepEqual(findExtensions(':text, form :text:visible, :input'), [':text', ':visible', ':input']);
});

test('text in quotes and in attribute selectors is not a selector', () => {
  assert.deepEqual(findExtensions('p:contains("Total: 420")'), [':contains']);
  assert.deepEqual(findExtensions('a[href$=":first"]'), []);
  assert.deepEqual(findExtensions("a[title='Help: :hidden']:last"), [':last']);
  assert.deepEqual(findExtensions('a[title="x!=y"]'), []);
});

test('an escaped character belongs to the name it is in', () => {
  assert.deepEqual(findExtensions('#step\\:first, #order\\:42 :checkbox'), [':checkbox']);
  assert.deepEqual(findExtensions('#price\\.total:visible'), [':visible']);
});

test('the attribute-not-equal selector is reported as [!=]', () => {
  assert.deepEqual(findExtensions('input[name!="token"]'), ['[!=]']);
  assert.deepEqual(findExtensions('li:odd, a[rel!="nofollow"], li:odd'), [':odd', '[!=]']);
});
A hint

Scan the string once, from left to right, with one regular expression that has an alternative for each kind of piece: an escaped character (a backslash and the character after it), a quoted string ("…" or '…'), an attribute selector ([…], which may hold a quoted value) and a pseudo-class (: followed by letters and hyphens). selector.matchAll(re) gives the pieces in order. Skip the escapes and the quoted strings, report [!=] for an attribute selector whose operator, right after the attribute's name, is !=, and report a pseudo-class only when its whole name is in a Set of the extensions: :first must not match the start of :first-child.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

HTML, CSS & JS Playground (Online Editor) Paste the checkout page into the playground and try :visible, CSS.escape() and the positional selectors in a real browser. Regex Tester Build the regular expression for the exercise against selector strings of your own.

Then practise every idea of the lesson: the questions begin where your answers are weakest, and each choice explains itself as soon as you pick it:

Practice that adapts to you

Practise this lesson

12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Why does jQuery's documentation advise selecting with a standard selector first and then calling .filter(':visible')?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Queries with an extension cannot use the speed of the browser's querySelectorAll()

    An extension makes querySelectorAll() refuse the selector, so jQuery matches all of it on its own. Selecting with standard CSS and filtering the smaller set afterwards keeps the browser's engine in use.

  2. Question 2 of 12 Legacy code uses $('ul:has(li.sale)'). What happens to that selector when the code moves to document.querySelectorAll()?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: It keeps working, because :has() is standard CSS now

    Not every pseudo-class in jQuery code is an extension. :has() and :checked are standard and move to the DOM unchanged; the exercise of this lesson leaves them out of its report for that reason.

  3. Question 3 of 12 Which elements does :input match?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: input, select, textarea and button elements

    :input names every form field, so its standard rewrite is the selector list input, select, textarea, button.

  4. Question 4 of 12 What does $(':contains(Total)').addClass('marked') mark on the checkout page?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The html and body elements as well as the paragraph

    Write :contains() after the element you mean, or in DOM code filter a standard selection by textContent, as rewrites.js does for the p elements.

  5. Question 5 of 12 Which native test comes closest to jQuery's :visible?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: el.checkVisibility(), which is false for an element without a layout box

    jQuery's own definition is that offsetWidth, offsetHeight or getClientRects() is non-zero. checkVisibility() asks the same question of the layout, and both need a page that the browser has laid out.

  6. Question 6 of 12 Which method call returns what li:gt(2) returns?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: $('li').slice(3)

    .slice(start, end) covers :lt() and :gt(): :lt(n) is .slice(0, n) and :gt(n) is .slice(n + 1).

  7. Question 7 of 12 What is the status of :first, :eq() and :even in jQuery 4.0.0?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Deprecated since 3.4 and still working in 4.0.0

    jQuery 3.4 deprecated the positional selectors in favour of methods that do the same job. They still work, so nothing forces the change until the code moves to the DOM.

  8. Question 8 of 12 You must find every positional selector in a large codebase before removing jQuery. Why is loading jQuery Migrate 4.0.2 not enough?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Migrate does not warn about positional selectors, so the code has to be searched

    Some changes leave no warning behind, so an audit searches the selector strings themselves, which is what the exercise of this lesson does.

  9. Question 9 of 12 What does $.escapeSelector('price.total') return?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: price\.total

    $.escapeSelector() and CSS.escape() put a backslash before each character with a meaning in CSS, and write a leading digit as its code point.

  10. Question 10 of 12 Which built-in function of the browser escapes a string for a selector the way $.escapeSelector() does?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: CSS.escape()

    In code without jQuery, build selectors from ids with CSS.escape(), or use getElementById() when the selector is only the id.

  11. Question 11 of 12 Legacy code defines jQuery.expr[':'].instock = (el) => el.dataset.stock !== '0'. What happens on jQuery 4.0.0?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: It works, and jQuery Migrate reports the old name as deprecated

    Rename jQuery.expr[':'] and jQuery.expr.filters to jQuery.expr.pseudos, as jQuery Migrate's warning says; the behaviour is the same.

  12. Question 12 of 12 The stock of each item is in a data-stock attribute. What is the best replacement for $('li:stockof(4)') in code without jQuery?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: document.querySelectorAll('li[data-stock="4"]')

    Before translating a custom pseudo-selector, check whether a standard one says the same thing; when the test needs code, select with standard CSS and filter with a function.

Interview questions

Warm-up (fresher to 2–5 years): why can a selector work in $() and throw in document.querySelectorAll()?

jQuery first hands a selector to the browser’s querySelectorAll(). When the browser refuses it, because it uses a jQuery extension such as :checkbox, :first or :contains(), jQuery catches the SyntaxError and matches the selector with its own engine, so the page never sees an error. document.querySelectorAll() has no such fallback and throws. A lone id is a second case: $('#1st-visit') goes through getElementById(), while querySelectorAll() refuses #1st-visit because a CSS name cannot begin with a digit. Before removing jQuery, rewrite the extensions as standard CSS or methods such as .first(), and escape or look up unusual ids.

The bank of this module holds two harder ones: clearing a whole codebase of extensions, and a custom selector that went quiet after an upgrade. Sketch your own answer first, then compare it with the first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Your team will replace jQuery's selectors with document.querySelectorAll() across a large codebase. How do you find the jQuery-only selectors, translate them, and show that each translation selects the same elements as before?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    After an upgrade to jQuery 4, $('tr:status(paid)') returns no rows and the console shows no error. The :status pseudo-selector was written years ago. What is wrong, and how do you fix it so that it works on both 3.7 and 4.0?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • jQuery tries every selector on querySelectorAll() first and matches extensions with its own engine, so code that calls the DOM directly throws a SyntaxError for each extension it still holds.
  • Most form extensions have standard rewrites, but :text also matches inputs without a type, :checked also matches selected options, and :contains() also matches every ancestor of the words.
  • Positional selectors such as :first, :even and :gt() count the whole set from 0 and are deprecated since 3.4; .first(), .even(), .eq() and .slice() do the same job, and Migrate does not report them.
  • Escape ids that hold dots, colons or a leading digit with $.escapeSelector() or CSS.escape(), or look a lone id up with getElementById().
  • A custom pseudo-selector that reads match[3] finds nothing in jQuery 4; write it with jQuery.expr.createPseudo(), or replace it with a standard attribute selector.

Terms in this lesson

positional selector
A jQuery selector extension that picks elements by their position in the whole matched set, counted from 0: :first, :last, :eq(), :even, :odd, :lt() and :gt(). They are deprecated since jQuery 3.4 in favour of methods such as .first() and .eq().
pseudo-class
The part of a selector that starts with a colon and tests a state or a position of an element, such as :checked or :first-child in CSS. jQuery adds pseudo-classes of its own and lets code define more on jQuery.expr.pseudos.
selector extension
A selector that jQuery understands but CSS does not, such as :checkbox, :first or :contains(). jQuery matches it with its own engine; the browser's querySelectorAll() throws a SyntaxError for it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.