ESC

Type to search the knowledge base.

CSS Selector Generator

Build a unique CSS path from a node to the root: id when unique, then nth-child among siblings, and stop before you invent a full query engine.

intermediate6 min read
  • javascript
  • dom
  • css
  • interview

A bug report says “the third button.” The candidate walks className, concatenates .btn.primary, and ships a selector that matches twelve nodes after the next CSS-module hash change. A CSS selector generator for interviews is not a pretty locator and not a CSS parser. It is a unique path from a node to the root: stop at a unique id, otherwise tag:nth-child(i) among element siblings.

Classes are unstable (hashes, A/B tests, leftover utility noise). querySelector is how you verify. This function is how you build the string. Stop before you implement combinators, :has(), or a full query engine.

Key takeaways

  • Walk toward the root. Unshift each step so the string reads root to node.
  • If id is unique in the tree, # plus CSS.escape(id) and stop. Duplicate ids do not count.
  • Otherwise tagName.toLowerCase() plus :nth-child(i) among parent.children (1-based, elements only).
  • Do not use class lists as identity. They are styling, not a primary key.
  • This generates a selector. It does not parse CSS, and it is not querySelector.

A unique path from node to root, ids first, nth-child as fallback

Goal: document.querySelector(path) === el for an element attached to a document, with a selector that does not depend on classes.

Procedure:

  1. Reject non-elements.
  2. While the current node is an element:
    • If it has an id and getElementById returns this node, unshift # + CSS.escape(id) and break. That id is unique enough to be the path prefix.
    • Else compute the 1-based index among parent.children (element siblings, not childNodes). Unshift tag:nth-child(index).
    • Move to parentElement. If there is no parent element, unshift the tag (the root html, or a detached top) and stop.
  3. Join steps with > (child combinator). Descendant space would still match, but child is stricter and cheaper to read.
function cssPath(el) {
  if (!(el instanceof Element)) {
    throw new TypeError('cssPath expects an Element');
  }

  const parts = [];
  let node = el;

  while (node.nodeType === Node.ELEMENT_NODE) {
    const id = node.getAttribute('id');
    if (id) {
      const root = node.getRootNode();
      const byId = root instanceof Document ? root.getElementById(id) : null;
      if (byId === node) {
        parts.unshift('#' + CSS.escape(id));
        break;
      }
    }

    const tag = node.tagName.toLowerCase();
    const parent = node.parentElement;
    if (!parent) {
      parts.unshift(tag);
      break;
    }

    const siblings = parent.children;
    let index = 0;
    for (let i = 0; i < siblings.length; i++) {
      if (siblings[i] === node) {
        index = i + 1;
        break;
      }
    }
    parts.unshift(tag + ':nth-child(' + index + ')');
    node = parent;
  }

  return parts.join(' > ');
}
<body>
  <main>
    <ul>
      <li>one</li>
      <li id="pick">two</li>
    </ul>
  </main>
</body>

cssPath(pick) is #pick if that id is unique in the document. Duplicate the id on an earlier node and you fall through to something like html > body > main > ul > li:nth-child(2).

:nth-child counts element siblings. parent.children matches that. childNodes would count text and comments, and the selector would point at the wrong child. :nth-of-type counts among the same tag. The prompt asked for tag:nth-child(i), which means “this tag, and it is the ith element child.” span:nth-child(2) is a span that is the second child, not the second span. If the second child is a div, that selector matches nothing. Using the node’s real index among children keeps it unique among siblings.

Unique id, then escape

Document.getElementById does not use CSS syntax. id="user.email" looks up as that whole string. Putting it in a selector without escape becomes #user.email, which is id user plus class email. CSS.escape turns special characters (., :, digits at the start, spaces) into identifier escapes.

HTML does not enforce unique ids. getElementById returns the first match in tree order. byId === node is the uniqueness check: this node is the one #id would select. If a duplicate sits earlier, ignore the id and keep walking with nth-child. Do not trust el.id alone.

getAttribute('id') is better than el.id for the empty-string case: a present empty id is useless as a selector. Skip it.

getRootNode() keeps detached subtrees and shadow trees from consulting the wrong document. A node in a DocumentFragment has no getElementById; we skip the id shortcut and use nth-child up to the fragment’s top element.

Why classes stay out

classList is a styling API (classList and dataset). Tokens change when CSS modules hash, when a utility framework adds mt-2, when a feature flag flips a skin. Two nodes can share a class. One node can have no class. A generator that emits .btn.primary is a guess, not a unique path.

If a later round asks for a “shortest unique selector,” you may add a class when querySelectorAll of that class in the parent has length 1. That is extra. The base prompt is id, else nth-child. Do not start there.

This is also not getElementsByClassName. That walk finds nodes by class tokens. This walk names one node.

Not a CSS parser, not a query engine

You generate a string. You do not parse a selector, expand combinators, or implement :nth-child(An+B). Verification is root.querySelector(path) === el. Invalid characters in the id are CSS.escape’s problem, not a parser you write.

Stop the scope:

  • No jQuery-style :eq(), :contains(), :visible.
  • No walking into shadow roots through the host (parentElement already stops at the shadow tree’s top; piercing would need parentNode plus host).
  • No SVG className.baseVal special case beyond tagName (SVG elements still have tagName and id).
  • No generating attribute selectors for every data-*. That is another pretty-locator product.

querySelector is the consumer. Attribute and sibling selectors explain combinators if they ask why > vs space. Specificity is a follow-up only if they want to know that #id beats a chain of tags.

Footguns of a CSS path generator

  1. Unescaped id. #a.b is two simple selectors. CSS.escape is not optional.
  2. nth-child off childNodes. Text nodes shift the index. Use children.
  3. 0-based index. CSS is 1-based. nth-child(0) is invalid.
  4. Assuming ids are unique. Check getElementById === node.
  5. Using className on SVG. It is not a string. Irrelevant if you ignored classes.
  6. Shadow DOM. parentElement is null at the top of the shadow tree. You get a path valid inside that tree, not from document.
  7. document.querySelector on a detached node. The path may be unique in the fragment and match something else in the document. Verify against el.getRootNode().

Uppercase tagName in HTML documents is DIV. Selectors are case-insensitive for HTML elements in HTML docs, but lowercase is the usual emit. XML/SVG is case-sensitive; toLowerCase() can lie there. Mention it if they bring SVG.

Interview answer (out loud)

“I walk from the node toward the root. If the element has an id and getElementById returns that same node, I emit # plus CSS.escape of the id and stop. Otherwise I emit the lowercase tag with :nth-child of its 1-based index among parent.children, never childNodes. Classes stay out because they are not unique and they churn. Duplicate ids fall back to nth-child. This is a path generator I can round-trip with querySelector. It is not a CSS parser and not a shortest-selector search.”

Further reading

Related guides