Lesson 05 / 26
Attribute Selectors
Matching by an attribute's presence and value; exact match, list membership, hyphen-prefixed match, and substring matches, along with the case-sensitivity flag.
Contents
Selectors have so far looked at an element’s name, class, id, and position in the tree. There is one more layer of information in the document: attributes. A link’s target address, a form field’s type, a measurement unit attached to a cell with a data attribute — all of it sits there, and none of it is written in the class list.
This lesson defines attribute selectors. There are seven matching forms, and the differences between them are string operations, so all of them can be demonstrated by running code.
Seven Matching Forms
An attribute selector is written inside square brackets. Its simplest form asks about the attribute’s presence; it does not look at its value.
| Notation | Condition |
|---|---|
[name] |
does the attribute exist |
[name="x"] |
is the value exactly x |
[name~="x"] |
is x in the space-separated value list |
[name|="x"] |
is the value x, or does it start with x- |
[name^="x"] |
does the value start with x |
[name$="x"] |
does the value end with x |
[name*="x"] |
does the value contain x |
The last three are substring matches and treat the value as an unstructured piece of text.
The middle two assume the value has a structure: ~= a space-separated list, |= a
hyphen-segmented code.
The following program implements all seven forms and tests them against elements taken from the station page.
// attr-match.mjs — implements the seven attribute matching forms function match(form, name, value, attrs, insensitive = false) { if (!(name in attrs)) return false; let v = attrs[name]; let d = value; if (insensitive) { v = v.toLowerCase(); d = d.toLowerCase(); } switch (form) { case "has": return true; // [name] case "=": return v === d; // [name="x"] case "~=": return v.split(/\s+/).includes(d); // [name~="x"] case "|=": return v === d || v.startsWith(d + "-"); case "^=": return d !== "" && v.startsWith(d); case "$=": return d !== "" && v.endsWith(d); case "*=": return d !== "" && v.includes(d); default: throw new Error("unknown form: " + form); } } const elements = [ { name: "a", attrs: { href: "https://data.example.test/station.pdf", rel: "noopener help" } }, { name: "a", attrs: { href: "/measurements", rel: "help" } }, { name: "a", attrs: { href: "mailto:[email protected]" } }, { name: "input", attrs: { type: "number", name: "temperature", required: "" } }, { name: "input", attrs: { type: "text", name: "observer" } }, { name: "td", attrs: { "data-unit": "degrees-C" } }, { name: "td", attrs: { "data-unit": "degrees" } }, { name: "html", attrs: { lang: "en-US" } }, ]; const trials = [ ["[required]", "has", "required", ""], ['[type="number"]', "=", "type", "number"], ['[rel~="help"]', "~=", "rel", "help"], ['[lang|="en"]', "|=", "lang", "en"], ['[href^="https://"]', "^=", "href", "https://"], ['[href$=".pdf"]', "$=", "href", ".pdf"], ['[href*="measure"]', "*=", "href", "measure"], ['[data-unit|="degrees"]', "|=", "data-unit", "degrees"], ]; for (const [notation, form, name, value] of trials) { const found = elements.filter((e) => match(form, name, value, e.attrs)); console.log(`${notation.padEnd(26)} -> ${found.length}`); for (const f of found) { const attrs = Object.entries(f.attrs).map(([k, v]) => `${k}="${v}"`).join(" "); console.log(` <${f.name} ${attrs}>`); } } // case sensitivity console.log("--- case sensitivity ---"); const element = { type: "NUMBER" }; console.log('[type="number"] ->', match("=", "type", "number", element)); console.log('[type="number" i] ->', match("=", "type", "number", element, true));
[required] -> 1 <input type="number" name="temperature" required=""> [type="number"] -> 1 <input type="number" name="temperature" required=""> [rel~="help"] -> 2 <a href="https://data.example.test/station.pdf" rel="noopener help"> <a href="/measurements" rel="help"> [lang|="en"] -> 1 <html lang="en-US"> [href^="https://"] -> 1 <a href="https://data.example.test/station.pdf" rel="noopener help"> [href$=".pdf"] -> 1 <a href="https://data.example.test/station.pdf" rel="noopener help"> [href*="measure"] -> 1 <a href="/measurements" rel="help"> [data-unit|="degrees"] -> 2 <td data-unit="degrees-C"> <td data-unit="degrees"> --- case sensitivity --- [type="number"] -> false [type="number" i] -> true
The Difference Between List Membership and Substring
[rel~="help"] returned both links. One has a rel value of "noopener help", the
other’s is "help". The tilde sign splits the value on whitespace and searches the list;
it is therefore independent of order and is not tripped up by a partial word.
[rel*="help"] would give the same result here, but that notation is open to false
matches: the value rel="helpless" would also match, even though there is no list
membership there. If an attribute carries a space-separated list of keywords,
~= is written, not *=.
The same distinction holds for |=. The notation [lang|="en"] matches the values
lang="en" and lang="en-US", and does not match lang="enough". This is a match
designed around the hyphen-segmented structure of language tags. The output’s last line
shows the same form working on a data attribute too: data-unit="degrees" and
data-unit="degrees-C" matched together.
A Class Selector Is an Attribute Selector
The ~= behavior in the output should look familiar. The class attribute is also a
space-separated list, and a class selector asks for list membership exactly. The two
notations are equivalent:
.missing { color: #8a1c1c; } [class~="missing"] { color: #8a1c1c; }
The second notation is not used in practice, but it puts a concept in its place: a class
is not a privileged concept of the language, it is a shorthand layered on top of an
attribute. In the same way, the #content selector returns the same set as
[id="content"] — the weight the two carry in the specificity calculation differs, but
their matching behavior is the same.
Case Sensitivity and Empty Values
The output’s last two lines show a trap. The [type="number"] selector did not match an
element whose value is NUMBER. Attribute values are case-sensitive as a general rule;
some HTML attributes have a defined case insensitivity, but rather than relying on that, it
is safer to state it explicitly with a flag:
input[type="number" i] { text-align: right; }
The i flag written before the closing bracket makes the match case-insensitive. The s
flag requests the opposite, an explicitly case-sensitive match.
The second trap is the empty string. The d !== "" check present in the program’s three
substring forms reflects a rule of the language: the notation [href*=""] matches
no element at all. Intuition says the opposite — every string contains the empty
string — but the definition excludes this case. If a production places a value into a
selector dynamically, a rule should be expected to silently die when the value is empty.
When an Attribute Selector Is the Right Tool
Adding a class is always possible, so why would an attribute selector be needed at all?
An attribute selector is the right tool in cases where the distinction is already in the
document. A form field’s type is written in the type attribute; adding
class="number-field" on top of that writes the same piece of information to two places
and takes on the obligation of keeping the two in sync. When the two sources drift apart,
which one is correct becomes unclear.
The same reasoning holds for a required state:
/* station.css — step 4: form fields and outbound links */ input[type="number"] { text-align: right; } input[required] { border-inline-start: 3px solid #8a5b00; } a[href^="http"] { padding-inline-end: 1em; } td[data-unit|="degrees"] { font-variant-numeric: tabular-nums; }
The second rule reads required fields from the required attribute already present in the
document. When a field stops being required, the attribute is removed and the style drops
by itself; no class update is needed. Validation behavior and visual indicator are fed by
the single same source.
The third rule’s reasoning is different: it makes room for outbound links. An address
starting with http is not an author decision, it is the address’s own shape; marking it
with a class as well would be needless repetition.
The limit is worth stating too. An attribute selector cannot be invented if the distinction is not in the document. If the information “this paragraph is a warning” is not written in any attribute, a class name is the place to carry it.
Summary
- An attribute selector is written with square brackets; its simplest form asks about the attribute’s presence, the remaining six forms test its value.
~=splits the value on whitespace and looks for list membership,|=matches the prefix of a hyphen-segmented code;^= $= *=treat the value as unstructured text.- A class selector returns the same set as
[class~="…"], an id selector the same as[id="…"]; both are shorthands for attribute matching. - Attribute values are matched case-sensitively; insensitivity is requested explicitly
with the
iflag. Substring matches written with an empty string match no element. - If the distinction already sits in an attribute in the document, an attribute selector is written; writing the same information into a class as well takes on the obligation of keeping two sources in sync.
Next Step
Every selector so far has looked at something written in the document: name, class, id, attribute, position. There are also things not written in the document — whether a link is being hovered over, whether a field has focus, which position a row sits at. The next lesson defines the pseudo-classes that select these states and the pseudo-elements that generate content not present in the tree at all.
To keep your progress and take notes, Log in
My notes
Log in to take notes.