skip to content

In CSS, what is the difference between the attribute selectors `[href^="/docs"]`, `[href$=".pdf"]`, `[href*="api"]`, `[class~="btn"]`, and `[lang|="en"]`, and what does adding the `i` flag do?

level: middleimportance: should knowfreq 50%

answer

  1. prefix, suffix, anywhere
  2. whole word versus partial substring
  3. hyphen rule exists for language subtags
  4. values are case-sensitive by default
  5. one letter before the closing bracket

basics

~20 s

They are attribute value operators: ^= matches a prefix, $= a suffix, *= a substring anywhere, ~= one whitespace-separated word in the value, and |= an exact value or one followed by a hyphen. Adding i before the closing bracket makes the comparison case-insensitive.

solid answer

~40 s

All five match on an attribute's value, differing in how the value is compared. `[href^="/docs"]` matches when the value *starts with* that string; `[href$=".pdf"]` when it *ends with* it; `[href*="api"]` when it contains it anywhere. `[class~="btn"]` is the whitespace-list operator: the value is split on whitespace and one whole word must equal `btn`, which is how it differs from `*=`, since `*=` would also match `btn-primary`. `[lang|="en"]` matches a value that is exactly `en` or begins with `en-`, which exists for hyphen-separated language subtags like `en-GB`. Attribute values in HTML are compared case-sensitively for most attributes, so `[href$=".pdf"]` misses `report.PDF`; writing `[href$=".pdf" i]` adds the case-insensitivity flag and matches both. Empty string values such as `[href^=""]` never match anything.

code

css · 6 lines
css
a[href^="https://"]        { color: teal; }   /* starts with */
a[href$=".pdf" i]          { font-weight: 600; } /* ends with, any case */
a[href*="?utm_"]           { opacity: 0.7; }  /* contains anywhere */
a[rel~="noopener"]         { border-bottom: 1px dotted; } /* whole word in list */
p[lang|="en"]              { quotes: '"' '"'; } /* en or en-GB */
input[type="text"][required] { border-color: crimson; } /* stacked = AND */

go deeper

for a junior

Know that square brackets match on attributes and be able to name what ^=, $= and *= do, plus the presence form like [disabled] with no value at all.

for a middle

Explain why ~= and *= give different results on a space-separated value, and know that values are compared case-sensitively unless you add the i flag before the closing bracket.

for a senior

Show judgment about coupling: styling off aria- or data- attributes keeps state in one place, while encoding URL structure in ^= rules creates a rule that breaks silently when paths change.

for a principal

Own the convention for state hooks across a codebase — deciding when state lives in attributes versus classes affects how script, accessibility and styling stay in sync as the product grows.

## The shape of an attribute selector An attribute selector is written in square brackets and tests an element's attributes rather than its tag or classes. The simplest form, `[disabled]`, is a presence test: it matches any element that has the attribute at all, regardless of value — which is exactly right for HTML boolean attributes, where `disabled` and `disabled=""` mean the same thing. The next form, `[type="checkbox"]`, is exact-match. Everything else is a *substring* or *list* operator that changes how the given string is compared with the attribute's value. ```css [disabled] { } /* attribute present */ [type="checkbox"] { } /* value is exactly "checkbox" */ ``` Quoting the value is optional only when it happens to be a valid CSS identifier; anything with a dot, slash, or space needs quotes. Quoting always is the safe habit. ## The three substring operators **`^=` — starts with.** `[href^="/docs"]` matches `/docs`, `/docs/intro`, and also `/docsomething`, because it is raw string prefix matching with no notion of path segments. **`$=` — ends with.** `[href$=".pdf"]` is the classic "put an icon on document links" selector. **`*=` — contains.** `[href*="api"]` matches anywhere in the value, including in the middle of a longer word. All three are literal substring comparisons over the whole attribute value, and all three fail to match when the given string is empty: `[href^=""]` matches nothing at all rather than everything. ## The two list operators **`~=` — one word from a whitespace-separated list.** The attribute value is split on whitespace and the selector matches if any resulting token equals the string exactly. For `class="btn btn-primary"`, `[class~="btn"]` matches because `btn` is a whole token; `[class~="btn-pri"]` does not, because `~=` never matches partial tokens. Contrast `[class*="btn"]`, which matches `btn-primary` and even `unbutton`. That distinction is the whole reason `~=` exists, and it matters whenever you match against space-separated attributes such as `class` or `rel`. In practice you would just write `.btn` for classes, but `~=` is the right tool for other list-valued attributes: `[rel~="noopener"]` correctly matches `rel="noopener noreferrer"`. **`|=` — exact value or value followed by a hyphen.** `[lang|="en"]` matches `lang="en"` and `lang="en-GB"` but not `lang="english"`. It was designed for hyphen-separated language subtags and is rarely useful elsewhere, though it works on any attribute. ## Case sensitivity and the `i` flag In HTML documents, attribute *names* are compared case-insensitively, so `[HREF]` and `[href]` behave identically. Attribute *values* are a different story: for most attributes the comparison is case-sensitive, which is why `[href$=".pdf"]` silently misses `/reports/Q3.PDF` — a real bug on sites where filenames come from a CMS. Selectors Level 4 added an ASCII case-insensitivity flag, written as a bare `i` before the closing bracket, after the value: ```css a[href$=".pdf" i] { padding-inline-start: 1.2em; } ``` That matches `.pdf`, `.PDF`, and `.Pdf`. The flag has been supported across browsers for years and is the correct fix for user-supplied or filesystem-derived values. It can be combined with any of the operators, including plain equality: `[type="checkbox" i]`. One wrinkle worth knowing: HTML defines a small set of legacy attributes whose values are matched case-insensitively by default regardless of the flag, so behaviour you observe with one attribute does not always generalise. When case matters to you, state it explicitly with `i` rather than relying on the default. ## Compounding and scoping Attribute selectors are simple selectors, so they compose into compounds with no separator, and several can be stacked to form an AND: ```css a[href^="http"]:not([href*="example.com"]) { } input[type="text"][required] { } ``` The second example matches only inputs that are both text-typed and required. ## Where they earn their keep Attribute selectors are the way to style based on state or data that lives in markup rather than in a class: `[aria-expanded="true"]`, `[data-state="loading"]`, `[hidden]`. They keep the styling hook and the accessibility or behavioural attribute in one place instead of forcing script to maintain a parallel class. The cost is that they are more fragile than a class when the underlying attribute value is not under your control — a URL restructure quietly breaks every `^=` rule that encoded a path prefix. ## Common mistakes Reaching for `*=` when `~=` is meant is the top one, because `*=` appears to work until a longer token appears in the list. Forgetting quotes around values containing dots or slashes is next. And assuming values are matched case-insensitively — they are not, unless you add the `i` flag.

  • Why would you pick `[rel~="noopener"]` over `[rel*="noopener"]`?
    `~=` splits the value on whitespace and requires a whole token to match, which is what `rel="noopener noreferrer"` needs. `*=` is a raw substring test, so it would also match a hypothetical longer token that merely contains the string. For any space-separated attribute value, `~=` expresses the intent correctly and `*=` matches too much.
  • When is an attribute selector a better styling hook than adding a class?
    When the attribute already carries the state — `aria-expanded`, `data-state`, `disabled`, `hidden`. Styling straight off it keeps one source of truth instead of asking script to maintain a parallel class that can drift out of sync with the accessible state. A class is better when the value is outside your control, such as a URL that may be restructured.
  • Does `[href^=""]` match every element with an href?
    No — the specification says a substring-matching selector with an empty string value represents nothing, so `^=`, `$=`, and `*=` with `""` all match zero elements. If you want every element that simply has the attribute, use the presence form `[href]` with no operator or value.

saying these in an interview costs you the question

  • Using *= where ~= is meant for class or rel lists
  • Assuming attribute values match case-insensitively by default
  • Thinking |= matches any value starting with the string
  • Believing [href^=""] matches everything with an href
  • Expecting ^= to respect URL path segment boundaries

context