I shipped a Flutter app in 2024 that had to pull structured fields out of HTML it fetched at runtime. The browser's querySelectorAll doesn't exist on the Dart VM, and every selector package I tried choked on a Level 4 pseudo-class. I built Cascadia to get out of that dead end, and this case study covers how it works and where it stops.
Cascadia is a Dart CSS selector engine that parses selector strings, matches nodes against an HTML tree, and serializes the result back to a CSS string to let you query documents anywhere Dart runs.
That single sentence skips the part most people actually need to know: why a language with no built-in DOM would want a selector engine at all. Let's fix that.
What is Cascadia, and why does a Dart project need a CSS selector engine?
Cascadia is a pure-Dart library, version 0.8.0, published by a verified pub.dev account tied to ayoubzulfiqar.com. It carries no browser APIs. That one fact is the reason it exists: the DOM lives in the browser, but Dart code routinely runs where no browser does.
Here's what I mean. A Flutter desktop app, a server-side scraper, and a CLI tool all speak Dart. None of them get document.querySelector for free. You either hand-roll string matching or drag a multi-megabyte web dependency that fights the Dart VM on every build.
The W3C froze Selectors Level 3 in 2018, and Level 4 is still a living document as of 2026. Cascadia tracks both specs. It covers 61 fully supported selectors, 31 that depend on match context, and 12 that parse but don't evaluate.
That last number stung me. I shipped an early build that parsed :hover and :focus yet always returned false, because the engine had no way to learn cursor state. A reader opened a GitHub issue asking why their test suite was green while the selector never matched a node. The repair was MatchContext, which now reports exactly what a state-dependent selector wants.
Most selector libraries dodge that honesty. They throw, or they quietly lie. Cascadia tells you what it's missing.
The catalog isn't invented to look big. The 70+ pseudo-classes and 25+ pseudo-elements come straight from MDN's public reference tables, so anyone can cross-check an entry against the source. That traceability is the point.
How does Cascadia parse and match a CSS selector?
The public surface is six functions, and you'll touch three every day. parse() turns a string into a reusable selector object. query() returns the first node that matches. queryAll() returns every node that matches.
Compilation matters more than it first sounds. A selector like ul li:nth-child(2n+1) > a[href^='https'] walks a small grammar: type, combinator, pseudo-class, attribute. Parsing it once and reusing the object beats re-parsing on every node inside a 10,000-element tree.
Specificity follows the spec. Cascadia computes (a, b, c) tuples for inline, id, class, and type counts, so a div.hero rule beats a bare div when you sort matches. The math shares the same pass as parsing, so you don't pay for it twice.
The matching walk is a depth-first descent. For each node, the engine checks the rightmost compound selector first, the standard optimization, then climbs ancestors for combinators. This trims the work on deep documents.
Attribute selectors carry their own weight. Cascadia resolves [href^='https'], [class~='item'], and the case-insensitive i flag exactly as the spec lists them, so a search box that filters by prefix behaves the same on the server as in Chrome. That consistency is why I trust it over a regex I'd write at 2 a.m.
Errors don't crash the run. Bad syntax raises a typed ParseException with the offset, and you catch it at the boundary. I learned to wrap parse() in a try block after a malformed user query once blanked an entire results panel in production.
Compile once, match many. compile() is a thin alias over parse(), but naming it signals intent: this selector lives for the whole request. On a batch job over 200 feeds, that single habit cut my parse calls from millions to dozens.
Pseudo-elements sit behind a separate door. parse() rejects them; parseWithPseudoElements() accepts them. The split keeps ordinary queries fast and makes the rare case explicit.
Which CSS Selectors Level 3 and Level 4 features does Cascadia cover?
Level 3 is a frozen spec. Level 4 is still moving, which means any engine that claims "full" coverage is dating its own claim. Here's the honest split Cascadia draws today.
| Feature group | CSS Selectors Level 3 (2018) | CSS Selectors Level 4 (current) |
|---|---|---|
| Simple selectors | tag, class, id, universal | same, plus :is(), :where(), extended :not() |
| Attribute | [attr], [attr=val], prefixes | same, plus case-insensitive i flag |
| Pseudo-classes | :hover, :focus, :nth-child | adds :has(); Cascadia also handles :state() and :target-within when you pass a match context |
| Combinators | descendant, child, sibling | same four combinators, plus leading >, + and ~ inside :has() |
| Pseudo-elements | ::before, ::after | adds ::marker, ::part, ::slotted |
| Container queries | none | :in-container parses but never matches (container size is runtime layout state) |
| View transitions | none | ::view-transition pseudo-elements |
Cascadia ships those 70+ pseudo-classes and 25+ pseudo-elements drawn from MDN. The list mirrors the public reference pages so a reader can verify each entry without trusting me.
:has() reshaped how I write scrapers. Before it landed, "find the card that holds a sold-out badge" took two passes. One selector does it now. That's the Level 4 win in a single line.
Shadow DOM support closes another gap. Slotted content and shadow trees resolve through the same walker, so a component's internals query like the document's surface. Most headless parsers skip this and break on real web components.
Container queries are the honest gap. Cascadia parses :in-container, but a container's size is layout state that a static HTML tree doesn't have, so the selector never matches. If a Flutter widget needs size-dependent matching, measure it in Flutter and filter the results there.
How does Cascadia compare to the browser's querySelectorAll?
The browser hands you querySelectorAll at zero cost. The catch is that it only runs where a browser does. Here's how the two divide the work.
| Capability | Cascadia (pure Dart) | querySelectorAll (browser) |
|---|---|---|
| Runtime | Dart VM, Flutter, server | browser only |
| Level 4 support | 61 supported, 12 parse-only | varies by engine version |
| Pseudo-elements | via parseWithPseudoElements() | limited, engine-specific |
| Return type | typed, serializable Node list | live NodeList |
| Serialization | back to a CSS string | none |
| State selectors | MatchContext reports needs | reads live DOM state |
querySelectorAll wins on live state. It sees :hover because the browser renders it. Cascadia can't, by design, since no render loop exists in a CLI. MatchContext is the honest compromise: it tells your code what's absent instead of guessing.
Pick Cascadia when Dart runs headless. Pick the browser when one is already present.
The serialization column is the sleeper advantage. Because Cascadia turns a selector object back into a CSS string, you can store, diff, and log selectors as data. The browser gives you a NodeList and walks away.
What can and can't a pure-Dart selector engine do?
Every tool draws a line. Here's where Cascadia draws its line, stated plainly so you can plan around it before you build.
| Capability | Cascadia CAN | Cascadia CANNOT |
|---|---|---|
| Parse Level 3 & 4 | read 61 selectors fully | guarantee every draft Level 4 selector ships |
| Match static HTML | walk the full tree | read live hover or focus state |
| Run on Flutter | no browser APIs needed | replace the browser's inspector |
| Serialize CSS | round-trip to a string | reflow or paint anything |
| Container queries | parse :in-container | evaluate layout-dependent sizes |
| Shadow DOM | resolve slotted content | recompute a component's render |
The cannot column is the part most marketing pages hide. I'd rather you know it before you build than after. A selector engine parses and matches; it doesn't lay out a page.
That boundary is also its strength. Because it ignores rendering, it stays small, fast, and free of browser quirks that shift between Chrome, Firefox, and Safari. A rule that matches in one engine matches in all of them.
Think of the line as a promise. Cascadia owns parsing, matching, and serialization, and it names everything outside that set instead of faking it. That's the trade I'd make every time over a black box that guesses.
How do you query the DOM with Cascadia in practice?
The html package builds the tree; Cascadia walks it. The two stay separate on purpose, so you can swap the parser without touching your selectors.
import 'package:cascadia/cascadia.dart';
import 'package:html/parser.dart' as html;
void main() {
final doc = html.parse(
'<ul><li class="item">A</li><li class="item">B</li></ul>',
);
final items = queryAll(doc, 'li.item');
print(items.length); // 2
}Pre-compile when you reuse a selector. final isItem = compile('li.item'); then filter a node list. On a 50,000-node document that difference shows up in the profile.
Pseudo-elements need the right entry point. parse('a::before') throws; parseWithPseudoElements('a::before') works. I hit that wall on day one and lost an afternoon to it. The API name is the guardrail.
For grouped selectors, parseGroup() returns a SelectorGroup you can match against the same tree. That's how you mirror a stylesheet's comma-separated rule in one call.
State-dependent matches hand back a MatchContext requirement instead of a boolean. Feed it the context your app tracks, and the same selector that returned false on a server returns true in a widget that knows focus.
Where does Cascadia fall short?
Version 0.8.0 is pre-1.0, so the API can still shift. I treat breaking changes as likely until 1.0 lands, and I pin the version in every project that depends on it.
Coverage gaps are real. Twelve selectors parse but don't evaluate, and 31 need a MatchContext you must supply. If your stylesheet leans on :nth-col() or other grid selectors, Cascadia will read them and then decline to match.
Performance is good, not magic. It beats re-parsing, and it loses to a hand-tuned C matcher on micro-benchmarks. For the document sizes Flutter and server apps actually see, the gap won't matter.
The uncomfortable truth is maintenance. One person wrote this, and Level 4 keeps moving. A selector you depend on today might sit unimplemented next quarter. The repo stays open, but the bus factor is one.
It's not about replacing the browser. It's about covering the Dart-shaped hole the browser never filled.
Serialization round-trips, but it won't minify or pretty-print your CSS for you. Reach for a dedicated formatter when output hygiene matters more than a quick echo.
FAQ
What is a CSS selector engine?
A CSS selector engine is a library that takes a selector string like div.hero > a and finds the nodes in a document that match it, following the W3C Selectors spec for parsing and specificity.
Is Cascadia production-ready at version 0.8.0?
It's stable for parsing and matching in real apps, but the pre-1.0 tag means the API can change. Pin the version in your pubspec and read the changelog before you upgrade.
Does Cascadia work in Flutter web and mobile?
Yes. Because it's pure Dart with no browser APIs, the same code runs on Flutter Android, iOS, web, Linux, macOS, and Windows, plus the server-side Dart VM.
How is Cascadia different from querySelectorAll?querySelectorAll lives in the browser and reads live state; Cascadia runs headless on the Dart VM and reports state needs through MatchContext instead of guessing them.
Which pseudo-classes does Cascadia support?
It ships 70+ pseudo-classes from MDN, including :is(), :where(), :not(), :has(), :nth-child, and state selectors that report through MatchContext.
Why do some selectors parse but not match?
Twelve selectors parse for completeness while 31 need runtime context you supply. State-dependent ones return what they require via MatchContext rather than silently returning false.
What are pseudo-elements and how do I enable them?
Pseudo-elements like ::before and ::part describe generated content. Call parseWithPseudoElements() instead of parse(), or Cascadia throws on the :: token.
Does Cascadia calculate specificity?
Yes. It computes the standard (a, b, c) tuple during the parse pass, so you can sort competing matches the way a browser would without a second traversal.
Can Cascadia read :hover or :focus state?
Not directly. There's no render loop in a headless Dart process. Supply the cursor or focus state your app tracks through MatchContext, and the selector evaluates against it.
How do I install Cascadia?
Add cascadia: ^0.8.0 to your pubspec dependencies, then run dart pub get. The package page lives at pub.dev/packages/cascadia.
Is Cascadia open source, and what license?
Yes. It's BSD-3-Clause, published from github.com/ayoubzulfiqar/cascadia by a verified pub.dev publisher, so you can read and patch every line.