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.
- 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
idis unique in the tree,#plusCSS.escape(id)and stop. Duplicate ids do not count.- Otherwise
tagName.toLowerCase()plus:nth-child(i)amongparent.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:
- Reject non-elements.
- While the current node is an element:
- If it has an
idandgetElementByIdreturns 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, notchildNodes). Unshifttag:nth-child(index). - Move to
parentElement. If there is no parent element, unshift the tag (the roothtml, or a detached top) and stop.
- If it has an
- 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 (
parentElementalready stops at the shadow tree’s top; piercing would needparentNodeplushost). - No SVG
className.baseValspecial case beyondtagName(SVG elements still havetagNameandid). - 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
- Unescaped id.
#a.bis two simple selectors.CSS.escapeis not optional. nth-childoffchildNodes. Text nodes shift the index. Usechildren.- 0-based index. CSS is 1-based.
nth-child(0)is invalid. - Assuming ids are unique. Check
getElementById === node. - Using
classNameon SVG. It is not a string. Irrelevant if you ignored classes. - Shadow DOM.
parentElementisnullat the top of the shadow tree. You get a path valid inside that tree, not fromdocument. document.querySelectoron a detached node. The path may be unique in the fragment and match something else in the document. Verify againstel.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
getElementByIdreturns that same node, I emit#plusCSS.escapeof the id and stop. Otherwise I emit the lowercase tag with:nth-childof its 1-based index amongparent.children, neverchildNodes. 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 withquerySelector. It is not a CSS parser and not a shortest-selector search.”
Further reading
Related on this site
- Walk the DOM like getElementsByClassName
- DOM traversal with querySelector
- CSS selectors: attribute and sibling
- classList and dataset
- CSS specificity
- Creating and updating DOM nodes
Related guides
- Serialize a Form to JSONRead FormData, group duplicate names into arrays, skip disabled controls, and handle file inputs without pretending they are strings.
- Walk the DOM Like getElementsByClassNameDFS or BFS a subtree for class tokens. Live vs static NodeList, classList vs className split, and why querySelectorAll is the modern answer.
- HTML Escape and EncodeReplace the five XML characters in user text. This is encoding, not a sanitizer, and innerHTML of escaped text is still the wrong default.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.