---
title: "Generate a stable HTML element locator in C#"
url: https://recursiveai.org/onehtml/cookbook/generate-stable-element-locator/
description: "Generate a CSS selector that identifies an element without relying on changing class names. Complete .NET example with source and output."
version: "1.6.0"
---

# Generate a stable HTML element locator in C#

**onehtml** is a free, open-source HTML library for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

[NuGet](https://www.nuget.org/packages/RecursiveAI.OneHtml) · [GitHub](https://github.com/recursive-ai-oss/onehtml-csharp) · [Docs](https://recursiveai.org/onehtml/docs/) · [Download example (.zip)](https://recursiveai.org/onehtml/cookbook/generate-stable-element-locator/generate-stable-element-locator.zip)

Generate a CSS selector that identifies an element without relying on changing class names.

```shell
dotnet add package RecursiveAI.OneHtml
```

Requires [.NET 10](https://dotnet.microsoft.com/download/dotnet/10.0) (net10.0); .NET 9 and earlier aren’t supported.

```csharp
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Locators;

var doc = Document.Parse(
    "<button data-testid='save'>" +
    "Save</button>");
var generator = new LocatorGenerator(doc);
var button = doc.Root.QuerySelector("button");
if (!generator.TryGenerate(
    button, out var locator))
    throw new Exception("No locator available");
Console.WriteLine(locator);
```

```text
[data-testid="save"]
```



Unzip [the example](https://recursiveai.org/onehtml/cookbook/generate-stable-element-locator/generate-stable-element-locator.zip), then `dotnet run`.

## The full program

The full program resolves the selector against edited HTML. It accepts exactly one match, not merely the first match.

```csharp
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Locators;

var doc = Document.Parse("<button data-testid='save'>Save</button>");
var button = doc.Root.QuerySelector("button");
var generator = new LocatorGenerator(doc);
if (!generator.TryGenerate(button, out var locator))
    throw new InvalidOperationException("No locator available.");
Console.WriteLine(locator);
var edited = Document.Parse("<p>Draft</p><button data-testid='save'>Save now</button>");
var matches = edited.Root.QuerySelectorAll(locator!);
if (matches.Count != 1) throw new InvalidOperationException("Locator no longer unique.");
Console.WriteLine(matches[0].TextContent);
```

`dotnet run` prints:

```text
[data-testid="save"]
Save now
```

`TryGenerate` returns false when no locator can be generated, including an element beyond the documented 120-step limit. A saved locator is not guaranteed to survive later edits.

## Variations

Use a stable ID instead of a test hook. This standalone snippet prints `#save`.

```csharp
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Locators;

var doc = Document.Parse("<button id='save'>Save</button>");
var generator = new LocatorGenerator(doc);
Console.WriteLine(generator.Generate(doc.Root.QuerySelector("button")));
```

## Related recipes

- [Use CSS selectors in C#](https://recursiveai.org/onehtml/cookbook/css-selectors/)
- [Parse HTML in C#](https://recursiveai.org/onehtml/cookbook/parse-html/)

## The short answer

Use `LocatorGenerator.TryGenerate` to produce a unique CSS selector for an HTML node. Resolve it against edited HTML with `QuerySelectorAll` and reject missing or ambiguous matches.

## How do I generate a stable HTML element locator in C#?

Parse the HTML, select the target node, and pass it to `LocatorGenerator.TryGenerate`. The result is a CSS selector, not a browser object. Save that text if you need to look up the element in another parsed document. The full program shows the complete round trip: a paragraph is inserted before the button, its text changes, and the same test-hook selector still finds it.

“Stable” means avoiding some fragile inputs, not promising identity across arbitrary changes. Generation guarantees uniqueness in the original document. It cannot guarantee that another document still contains the element or preserves its identifying attributes. Query all matches after an edit and require exactly one before using the result. A zero-match result means the selector no longer resolves; multiple matches mean it is ambiguous.

The generator works on elements in the document tree. It does not generate locators for another document’s nodes or elements inside template contents. This is parsed-HTML processing, not browser automation: it does not click a button, wait for a page to load or run scripts. The [locator specification](https://recursiveai.org/onehtml/docs/locators/) defines its selection rules.

## How do I locate an HTML element by ID or attribute?

The generator tries a nonempty, non-hash-like ID first. Next come explicit test hooks, starting with `data-testid`, followed by `data-test`, `data-test-id`, `data-cy`, `data-qa`, `data-automation-id` and `data-id`. It then considers a tag with a name attribute, role with ARIA label, ARIA label alone, role alone, and the bare tag.

A candidate must be unique across the document’s candidate lists. If the target has no unique candidate, the generator walks up to a unique ancestor and appends child steps. These normally use `:nth-of-type`; non-HTML namespace cases use `:nth-child`. Adding a sibling of the same type can therefore change what a structural locator selects. An explicit test hook on the target avoids needing those position-based steps.

Attribute values and identifiers are serialized with CSS escaping, rather than pasted into a selector without quoting. Treat the returned string as the selector: do not remove its quotes or backslashes before querying it. Use a new generator for the new parsed document if you need to generate new locators after an edit.

## Can I generate a locator from an HTML class name?

This generator never uses classes, even a readable class that appears only once. Hash-like IDs are also rejected. The docs’ examples include `css-1x2y3z` and `Button_primary__3xK9z`: names like these can change when a build tool rebuilds a page.

The hash-like rule examines segments of at least five ASCII letters or digits. All-digit segments, sufficiently alternating digit-and-letter segments, and certain mixed-case letter segments count as generated names. It is a specified heuristic, not a claim to recognize every generated identifier. Prefer a deliberate test hook or meaningful ID when you control the HTML.

Ancestor paths have a maximum of 120 steps. `TryGenerate` reports failure rather than returning an unchecked selector when no suitable anchor is reachable within that limit. For saved selectors, uniqueness after an edit is still only a structural check; if your application needs content identity too, compare an element fingerprint’s attributes and text as an additional application decision.
