---
title: 'Attribute Selectors'
source: 'https://academia.sh/en/courses/css-fundamentals/attribute-selectors'
course: 'Visual Presentation with CSS'
language: en
updated: '2026-08-17T18:09:16+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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:observer@example.test" } },
  { 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:

```css
.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:

```css
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:

```css
/* 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 `i` flag. 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.
