---
title: "CSS selectors in C#"
url: https://recursiveai.org/onehtml/articles/css-selectors-csharp/
description: "Query HTML with CSS selectors in C#: QuerySelectorAll, :has() and other Level 4 selectors, tested in onehtml, AngleSharp and HtmlAgilityPack with Fizzler."
date: 2026-10-27
author: onehtml team
---

# CSS selectors in C#

Query HTML with CSS selectors in C#: QuerySelectorAll, :has() and other Level 4 selectors, tested in onehtml, AngleSharp and HtmlAgilityPack with Fizzler.

October 27, 2026 · onehtml team

To use CSS selectors in C#, parse the HTML with a library that implements them and call `QuerySelector` for the first match or `QuerySelectorAll` for every match, as you would with `document.querySelectorAll` in a browser. The .NET base class library has no CSS selector engine, so the choice of library decides which selectors work. onehtml implements Selectors Level 4, including `:has()`, `:is()`, `:where()` and `:not()` with a list. AngleSharp has its own engine, and HtmlAgilityPack gets selectors from the separate Fizzler package. This page shows the same query in all three and a table of which selectors each one accepted when we ran them. Every sample was compiled and run on .NET 10 with onehtml 1.0.0, AngleSharp 1.8.6, HtmlAgilityPack 1.13.1 and Fizzler 1.3.1.

These selectors work on HTML you already have, such as a downloaded page, a file or a rendered template. Selenium’s `By.CssSelector` and Playwright’s locators take the same syntax but query a live browser, which is a different job.

## The sample page

All the samples read this product list. Save it as `page.html` next to the project:

*page.html · HTML*

```
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Spring sale</title></head>
<body>
<h2>Kitchen</h2>
<p class="intro">Small appliances, shipped free.</p>
<ul id="products">
  <li class="product" data-sku="K-100"><a href="/p/kettle">Kettle</a> <span class="price">$39.00</span></li>
  <li class="product sale" data-sku="T-200"><a href="/p/toaster">Toaster</a> <span class="price">$24.50</span> <span class="badge">-30%</span></li>
  <li class="product sold-out" data-sku="M-300"><a href="/p/mixer">Stand mixer</a> <span class="price">$189.00</span></li>
  <li class="product sale" data-sku="B-400"><a href="/p/blender">Blender</a> <span class="price">$59.00</span> <span class="badge">-20%</span></li>
  <li class="product" data-sku="G-500"><a href="https://partner.example/grinder">Coffee grinder</a> <span class="price">$45.00</span></li>
</ul>
<h3>Manuals</h3>
<p><a href="/files/kettle-manual.PDF">Kettle manual</a> · <a href="/files/mixer-manual.pdf">Mixer manual</a></p>
<form>
  <label><input type="checkbox" name="instock" checked> In stock only</label>
  <label><input type="email" name="email" placeholder="you@example.com" required> Email</label>
  <button disabled>Notify me</button>
</form>
</body>
</html>
```

## Query HTML with CSS selectors in onehtml

*Terminal · NuGet*

```
dotnet new console -o Selectors
cd Selectors
dotnet add package RecursiveAI.OneHtml --version 1.0.0
```

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Selectors;
 
Document doc = Document.Parse(File.ReadAllBytes("page.html"));
Node root = doc.Root;
 
// Every match, in document order
foreach (Node item in root.QuerySelectorAll("#products > li"))
{
    string name = item.QuerySelector("a").TextContent!;
    string price = item.QuerySelector(".price").TextContent!;
    Console.WriteLine($"{item.GetAttribute("data-sku")}  {name,-15} {price}");
}
 
// Selectors Level 4: :has(), :not() with a list, the i flag, :nth-child(... of S)
Console.WriteLine($"With a badge:     {Skus(root.QuerySelectorAll("li:has(.badge)"))}");
Console.WriteLine($"Regular price:    {Skus(root.QuerySelectorAll("li:not(.sale, .sold-out)"))}");
Console.WriteLine($"Second sale item: {Skus(root.QuerySelectorAll("li:nth-child(2 of .sale)"))}");
Console.WriteLine($"PDF links:        {root.QuerySelectorAll("a[href$='.pdf' i]").Count}");
 
// First match, or the null node when nothing matches
Node soldOut = root.QuerySelector("li.sold-out a");
Node clearance = root.QuerySelector("li.clearance");
Console.WriteLine($"Sold out: {soldOut.TextContent}; clearance found: {!clearance.IsNull}");
 
// Matches and Closest start from an element you already have
Node badge = root.QuerySelector(".badge");
Console.WriteLine($"Badge in a sale item: {badge.Matches(".sale .badge")}, item: {badge.Closest("li").GetAttribute("data-sku")}");
 
// Form state comes from the attributes
Console.WriteLine($"Checked: {root.QuerySelectorAll("input:checked").Count}, required: {root.QuerySelectorAll("input:required").Count}, disabled: {root.QuerySelectorAll(":disabled").Count}");
 
// Compile a selector once when you run it against many documents
SelectorList external = SelectorList.Parse("a[href^='https:']");
Console.WriteLine($"External link: {root.QuerySelector(external).GetAttribute("href")}");
 
static string Skus(List<Node> items) => string.Join(", ", items.Select(i => i.GetAttribute("data-sku")));
```

*Output · Console*

```
K-100  Kettle          $39.00
T-200  Toaster         $24.50
M-300  Stand mixer     $189.00
B-400  Blender         $59.00
G-500  Coffee grinder  $45.00
With a badge:     T-200, B-400
Regular price:    K-100, G-500
Second sale item: B-400
PDF links:        2
Sold out: Stand mixer; clearance found: False
Badge in a sale item: True, item: T-200
Checked: 1, required: 1, disabled: 1
External link: https://partner.example/grinder
```

- **`QuerySelectorAll`** returns every match in document order as a `List<Node>`. **`QuerySelector`** returns the first match, or the null node when nothing matches. `Node` is a struct, so test `IsNull` rather than comparing with `null`.
- **Both work on any element,** not only the document. `item.QuerySelector(".price")` searches inside that one list item.
- **`Matches`** tests an element you already hold against a selector, and **`Closest`** walks up to the nearest ancestor that matches. Together they replace most hand-written parent loops.
- **Form pseudo-classes** such as `:checked`, `:required` and `:disabled` are decided from the attributes, because no script runs. `:hover` and `:focus` match only when you pass an interaction state.
- **`SelectorList.Parse`** compiles a selector once. Matching against a compiled list allocates nothing, which helps when the same selector runs over thousands of pages.
- **A selector that isn’t valid throws `SelectorSyntaxException`.** `SelectorList.TryParse` checks one without throwing.

The selector engine is tested against the Web Platform Tests for selectors: 2,537 cases pass, and the 43 known failures are all in tentative proposals that aren’t part of Selectors Level 4 ([selectors conformance](https://github.com/recursive-ai-oss/onehtml-csharp/blob/v1.0.0/docs/selectors.md#conformance-web-platform-tests-pinned-commit-in-the-notices-file)). The [CSS selectors recipe](https://recursiveai.org/onehtml/cookbook/css-selectors/) covers the remaining options, and the [selector reference](https://recursiveai.org/onehtml/docs/selectors/) lists every rule and limit.

## CSS selectors with HtmlAgilityPack and Fizzler

HtmlAgilityPack queries with XPath. For CSS selectors, its site lists Fizzler among its third-party libraries ([html-agility-pack.net](https://html-agility-pack.net/third-party-library)), which adds `QuerySelector` and `QuerySelectorAll` as extension methods on `HtmlNode` through the `Fizzler.Systems.HtmlAgilityPack` package. Fizzler describes itself as a “W3C Selectors (Level 3) parser” ([Fizzler README](https://github.com/atifaziz/Fizzler)).

*Terminal · NuGet*

```
dotnet add package HtmlAgilityPack --version 1.13.1
dotnet add package Fizzler.Systems.HtmlAgilityPack --version 1.2.1
dotnet add package Fizzler --version 1.3.1
```

*Program.cs · HtmlAgilityPack 1.13.1 · Fizzler 1.3.1*

```
using Fizzler.Systems.HtmlAgilityPack;
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("page.html");
 
// Fizzler adds QuerySelector and QuerySelectorAll as extension methods on HtmlNode
foreach (HtmlNode item in doc.DocumentNode.QuerySelectorAll("#products > li"))
{
    string name = item.QuerySelector("a").InnerText;
    string price = item.QuerySelector(".price").InnerText;
    Console.WriteLine($"{item.GetAttributeValue("data-sku", "")}  {name,-15} {price}");
}
 
Console.WriteLine($"Not on sale: {doc.DocumentNode.QuerySelectorAll("li:not(.sale)").Count()}");
 
// Selectors Level 4 syntax is rejected when the query runs
try
{
    doc.DocumentNode.QuerySelectorAll("li:has(.badge)").ToList();
}
catch (FormatException e)
{
    Console.WriteLine($"li:has(.badge) -> {e.Message}");
}
```

*Output · Console*

```
K-100  Kettle          $39.00
T-200  Toaster         $24.50
M-300  Stand mixer     $189.00
B-400  Blender         $59.00
G-500  Coffee grinder  $45.00
Not on sale: 3
li:has(.badge) -> Unknown functional pseudo 'has'. Only nth-child and nth-last-child are supported.
```

Two things to know before you choose this route:

- **Add `Fizzler` explicitly.** `Fizzler.Systems.HtmlAgilityPack` 1.2.1, the latest release, was published on 8 April 2020 ([NuGet](https://www.nuget.org/packages/Fizzler.Systems.HtmlAgilityPack/1.2.1)). It depends on `Fizzler` 1.2.0 or later, and without the third line above NuGet restores 1.2.0. In our run, 1.2.0 rejected `li:not(.sale)` with “Unknown functional pseudo ‘not’”, and `Fizzler` 1.3.1 ([NuGet](https://www.nuget.org/packages/Fizzler/1.3.1), 10 July 2024) accepted it.
- **Licenses.** `Fizzler` 1.3.1 is licensed LGPL-3.0-or-later, according to its NuGet listing. HtmlAgilityPack itself is MIT ([NuGet](https://www.nuget.org/packages/HtmlAgilityPack/1.13.1)).

## CSS selectors with AngleSharp

AngleSharp has selectors built in, on `IDocument` and `IElement`, with the DOM method names. Its feature list cites W3C CSS3 Selectors ([AngleSharp features](https://github.com/AngleSharp/AngleSharp/blob/devel/docs/general/02-Features.md)).

*Program.cs · AngleSharp 1.8.6*

```
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
 
var parser = new HtmlParser();
using FileStream stream = File.OpenRead("page.html");
IDocument doc = parser.ParseDocument(stream);
 
foreach (IElement item in doc.QuerySelectorAll("#products > li"))
{
    string name = item.QuerySelector("a")!.TextContent;
    string price = item.QuerySelector(".price")!.TextContent;
    Console.WriteLine($"{item.GetAttribute("data-sku")}  {name,-15} {price}");
}
 
var withBadge = doc.QuerySelectorAll("li:has(.badge)").Select(i => i.GetAttribute("data-sku"));
Console.WriteLine($"With a badge: {string.Join(", ", withBadge)}");
Console.WriteLine($"Badge item:   {doc.QuerySelector(".badge")!.Closest("li")!.GetAttribute("data-sku")}");
```

*Output · Console*

```
K-100  Kettle          $39.00
T-200  Toaster         $24.50
M-300  Stand mixer     $189.00
B-400  Blender         $59.00
G-500  Coffee grinder  $45.00
With a badge: T-200, B-400
Badge item:   T-200
```

In AngleSharp, `QuerySelector` returns `null` when nothing matches, hence the `!` operators above.

## Which selectors each library accepts

We ran 16 selectors against the sample page with each library and compared the number of matches with what Chrome 149’s `document.querySelectorAll` returns for the same page. Each number is the count of matching elements. “FormatException” means the library threw instead of matching. The table shows only what we ran, not everything each library supports.

| Selector | Level | Chrome 149 | onehtml 1.0.0 | AngleSharp 1.8.6 | HtmlAgilityPack + Fizzler 1.3.1 |
|---|---|---|---|---|---|
| `#products > li` | 2 | 5 | 5 | 5 | 5 |
| `li.sale a` | 1 | 2 | 2 | 2 | 2 |
| `a[href^="https:"]` | 3 | 1 | 1 | 1 | 1 |
| `li:not(.sale)` | 3 | 3 | 3 | 3 | 3 |
| `li:nth-child(odd)` | 3 | 3 | 3 | 3 | FormatException |
| `input:checked` | 3 | 1 | 1 | 1 | FormatException |
| `button:disabled` | 3 | 1 | 1 | 1 | FormatException |
| `li:not(.sale, .sold-out)` | 4 | 2 | 2 | 2 | FormatException |
| `:is(h2, h3) + p` | 4 | 2 | 2 | 2 | FormatException |
| `:where(h2, h3)` | 4 | 2 | 2 | 2 | FormatException |
| `li:has(.badge)` | 4 | 2 | 2 | 2 | FormatException |
| `li:has(> a[href^="https:"])` | 4 | 1 | 1 | 1 | FormatException |
| `a[href$=".pdf" i]` | 4 | 2 | 2 | 2 | FormatException |
| `li:nth-child(2 of .sale)` | 4 | 1 | 1 | 1 | FormatException |
| `input:required` | 4 | 1 | 1 | 1 | FormatException |
| `input:placeholder-shown` | 4 | 1 | 1 | 1 | FormatException |

When Fizzler meets a pseudo-class it doesn’t know, its error message lists `:first-child`, `:last-child`, `:only-child` and `:empty`, and for functional pseudo-classes `:nth-child()` and `:nth-last-child()`. Version 1.3.1 also accepts `:not()` with a single selector, as the table shows. Inside `:nth-child()` it accepted a plain number such as `2` but rejected both `odd` and `2n+1`. The Level column gives the first Selectors level that lists each feature.

## Select elements by text

CSS has no selector for an element’s text. jQuery’s `:contains()` isn’t in Selectors Level 3 or Level 4: Chrome throws a `SyntaxError` for it, and onehtml throws `SelectorSyntaxException`. (AngleSharp 1.8.6 accepted it in our run.) Select by structure, then filter with LINQ on `TextContent`:

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("page.html"));
 
// CSS has no text selector: select by structure, then filter on TextContent
List<Node> manuals = doc.Root.QuerySelectorAll("a[href]")
    .Where(a => a.TextContent!.Contains("manual", StringComparison.OrdinalIgnoreCase))
    .ToList();
 
foreach (Node link in manuals)
{
    Console.WriteLine($"{link.TextContent} -> {link.GetAttribute("href")}");
}
 
// The same idea finds the list item whose name matches, then reads a sibling
Node mixer = doc.Root.QuerySelectorAll("#products a")
    .First(a => a.TextContent == "Stand mixer")
    .Closest("li");
Console.WriteLine($"Stand mixer: {mixer.QuerySelector(".price").TextContent}, sold out: {mixer.Matches(".sold-out")}");
```

*Output · Console*

```
Kettle manual -> /files/kettle-manual.PDF
Mixer manual -> /files/mixer-manual.pdf
Stand mixer: $189.00, sold out: True
```

`TextContent` includes the text of every descendant, so filter on the most specific element you can select. Scripts and styles count as text too; the [visible text article](https://recursiveai.org/onehtml/articles/extract-visible-text-from-html-csharp/) shows how to leave them out.

## CSS selector or XPath?

Use CSS selectors for anything you can describe by tag, class, id, attribute or position. They are shorter, they are the query language of the web platform, and the same string works in the browser’s DevTools console, so you can try a selector on the live page before you put it in code. Use XPath when you need to select by text or move up the tree inside the query itself, and your library supports it: HtmlAgilityPack has XPath built in, and AngleSharp adds it through the [AngleSharp.XPath](https://www.nuget.org/packages/AngleSharp.XPath/2.0.10) package. onehtml has no XPath API, so in onehtml those cases are a selector followed by C#, as in the sample above.

Each row of this table returned the same elements on the sample page, the XPath through HtmlAgilityPack 1.13.1’s `SelectNodes` and the CSS through onehtml:

| XPath | CSS or C# | Matches |
|---|---|---|
| `//ul[@id='products']/li` | `#products > li` | 5 |
| `//li[contains(concat(' ', normalize-space(@class), ' '), ' sale ')]` | `li.sale` | 2 |
| `//a[starts-with(@href, 'https:')]` | `a[href^='https:']` | 1 |
| `//li[span[@class='badge']]` | `li:has(> span.badge)` | 2 |
| `//h3/following-sibling::p[1]` | `h3 + p` | 1 |
| `//a[contains(., 'manual')]` | `QuerySelectorAll("a").Where(a => a.TextContent!.Contains("manual"))` | 2 |

The class row is where CSS saves the most: `.sale` matches the whole word in a space-separated list, which XPath 1.0 needs the `concat` idiom to do safely. The [HtmlAgilityPack migration guide](https://recursiveai.org/onehtml/articles/migrate-from-htmlagilitypack/) translates more XPath expressions.

## Which to use

- **You want modern selectors such as `:has()`, `:is()` or `:not()` with a list:** onehtml or AngleSharp. Both matched Chrome on all 16 selectors above. onehtml publishes its Web Platform Tests results and every known failure.
- **You already use HtmlAgilityPack and your selectors are simple** (tags, classes, ids, attributes, child and descendant combinators): Fizzler covers them. Add `Fizzler` 1.3.1 explicitly and check its LGPL license against your project.
- **You need .NET Framework:** onehtml targets .NET 10 only, so use AngleSharp or HtmlAgilityPack. AngleSharp 1.8.6 ships .NET Framework 4.6.2 and 4.7.2 builds ([NuGet](https://www.nuget.org/packages/AngleSharp/1.8.6)).

## Where next

- [Parse HTML in C#](https://recursiveai.org/onehtml/articles/parse-html-csharp/): strings, files, URLs, encodings and fragments.
- [Web scraping in C#](https://recursiveai.org/onehtml/articles/web-scraping-csharp/): the same selectors on pages fetched with `HttpClient`.
- [HtmlAgilityPack vs AngleSharp](https://recursiveai.org/onehtml/articles/htmlagilitypack-vs-anglesharp/): how the two compare beyond selectors.
- [Getting started with onehtml](https://recursiveai.org/onehtml/docs/getting-started/).
