@amplitude/element-selector
Shared element-selector algorithm consumed by the autocapture SDK plugin, the app.amplitude.com tagging UI, and the Chrome extension visual tagger. One implementation, three surfaces, identical output.
Why it exists
The legacy autocapture cssPath algorithm anchored on any element id it found, which broke selector stability whenever a framework generated an id like :r5: (React's useId) or radix-A1B2C3 (Radix UI). It also leaned heavily on classes for sibling disambiguation, which broke selector stability whenever a library applied transient state classes like swiper-slide-active or Mui-selected.
This package replaces that algorithm with a strategy-chain orchestrator that filters autogenerated ids and library state classes out of the anchor selection, then disambiguates siblings positionally via :nth-of-type. The result: selectors that survive component re-mounts, carousel interactions, hover/focus state churn, and minor design tweaks.
See the design doc at packages/plugin-autocapture-browser/element-selector-strategy-v1-no-classes.md for the full background and rationale.
Install
This package lives in the Amplitude-TypeScript monorepo and is published to npm as @amplitude/element-selector. Consumers in this repo declare it as a workspace dependency:
{
"dependencies": {
"@amplitude/element-selector": "workspace:*"
}
}
The only runtime dependency is tslib; diagnostics accept any logger with warn and debug methods.
Usage
Standalone consumer
import { createSelectorEngine, resolveSelectorConfig } from '@amplitude/element-selector';
const config = resolveSelectorConfig({ enabled: true });
const engine = createSelectorEngine(config);
document.addEventListener('click', (event) => {
const target = event.target as Element;
const selector = engine.generate(target);
console.log(`Clicked: ${selector}`);
});
Autocapture SDK plugin
The autocapture plugin instantiates one engine per init() call, threads its loggerProvider through for diagnostic logs, and forwards remote-config updates via updateConfig:
import { createSelectorEngine, resolveSelectorConfig } from '@amplitude/element-selector';
const engine = createSelectorEngine(resolveSelectorConfig(remote, config.loggerProvider), {
logger: config.loggerProvider,
});
config.remoteConfigClient.subscribe(
'configs.analyticsSDK.browserSDK.autocapture.elementSelector',
'all',
(next) => {
engine.updateConfig(resolveSelectorConfig(next, config.loggerProvider));
},
);
Chrome extension visual tagger
The extension reads the page's already-live engine from window.amplitude.elementSelector (when present) and subscribes to onConfigChange so its preview stays in sync with the customer's runtime config:
const pageEngine = (window as any).amplitude?.elementSelector;
if (pageEngine) {
const unsubscribe = pageEngine.onConfigChange((nextConfig) => {
rerenderTaggingUI(nextConfig);
});
}
How the algorithm works
The orchestrator walks from the clicked element up through its ancestors, trying each strategy at each level in priority order. The first strategy that produces a unique-resolving selector wins; the trail of elements between the anchor and the original target is expressed as a positional descent.
Strategy chain
| 1 | explicitTrackingAttribute | [data-amp-track-id="value"] | Customer set the tracking attribute with a non-empty value |
| 2 | stableId | tag#id | Element has an id, the id isn't suppressed, and it doesn't match any autogenerated-id pattern |
Fallback
When neither strategy produces a unique selector anywhere on the walk, the engine invokes fallbackCssPath. The fallback walks from the target back up to <html>, expressing each step as tag:nth-of-type(n) and terminating early at the first unique id that survives the autogenerated-id filter.
Sibling disambiguation: :nth-of-type, never classes
The fallback does not use classes for sibling disambiguation. Classes are runtime-volatile in modern frameworks — Tailwind utilities change with design tweaks, CSS-in-JS hashes change every build, and library state classes (swiper-slide-active, is-open, Mui-selected) move between elements as the user interacts. Positional descent via :nth-of-type(n) is stable across all of these.
Defaults
enabled: false
v1 ships dormant. Autocapture continues to use the legacy cssPath until the remote-config payload flips enabled to true for an org.
explicitTrackingAttribute: "data-amp-track-id"
Customers can set this attribute on an element to anchor the selector on it explicitly. Setting it to an empty string is a suppression signal — the engine treats the element's id as if it didn't exist.
autogeneratedIdPatterns
The default list filters out ids that look generated rather than authored:
^:r[0-9a-z]+:$ | React useId | :r5:, :rk: |
^radix- | Radix UI primitives | radix-A1B2C3 |
^headlessui- | Headless UI components | headlessui-menu-button-:r5: |
^mui- | MUI internal id prefix | mui-12345 |
^[a-f0-9]{16,}$ (i) | UUIDs without hyphens, build hashes | a9b8c7d6e5f4321b |
-\d{8,}$ | Trailing long digit runs | session-1700000000 |
\d{4,} | Any 4+ consecutive digits | product-2134, row-1234 |
unstableClassPatterns
The default list (used by the fallback) catches classes that look stable in name but aren't stable in practice — Tailwind utilities, CSS-in-JS hashes (Emotion, styled-components, CSS modules, styled-jsx), and library runtime state classes (Swiper, MUI, Radix, BEM-style is-active).
maxAncestorWalkDepth: undefined
By default the walk runs all the way to <html>. Set this to a positive integer to cap the depth — useful for catastrophically deep DOMs where uniqueness checks could become expensive.
Remote config
The remote-config payload shape is defined in src/types.ts as ElementSelectorRemoteConfig and mirrored as a JSON Schema at schema/element-selector-remote-config.schema.json for the dashboard's remote-config editor and backend validation.
Customer-provided pattern lists fully replace the defaults — they're not merged. An empty array (autogeneratedIdPatterns: []) is an explicit "disable filtering" signal, not a "use defaults" signal. Invalid regex strings are dropped (with a warn log) rather than crashing the engine.
Diagnostics
Every path that catches an error accepts an optional ElementSelectorLogger, which is structurally compatible with the SDK's @amplitude/analytics-core logger provider. When wired up, the engine surfaces:
warn — invalid regex strings in remote config, subscriber exceptions during updateConfig fan-out
debug — malformed selectors produced by a strategy
When no logger is provided, the engine stays silent — preserving fire-and-forget semantics for consumers that don't want diagnostic noise.
Interactive testbed
Run the package's testbed page locally to click around the engine against the canonical scenarios, edit remote-config payloads in real time, and watch the diagnostic logger output:
pnpm --filter @amplitude/element-selector testbed
The testbed lives at packages/element-selector/testbed/ and imports the real built engine from lib/esm/index.js — there's no parallel implementation that can drift from the package. Every scenario from test/scenarios/ is rendered live; clicking any element shows the emitted selector, the strategy that won, and whether querySelector round-trips back to the clicked element.
Testing
pnpm --filter @amplitude/element-selector test
pnpm --filter @amplitude/element-selector lint
pnpm --filter @amplitude/element-selector build
The test suite includes:
- Primitive unit tests for the pattern packs, helpers, strategies, config resolver, orchestrator, fallback, and engine factory
- Schema drift guard — asserts the JSON Schema and the
ElementSelectorRemoteConfig TypeScript type stay in sync
- Scenarios corpus — a parameterized differential test that runs the engine over hand-picked fixtures (Swiper, MUI, Tailwind, semantic HTML, CSS-in-JS soup, etc.) and asserts both the expected selector and a
querySelector round-trip
- Property tests — generate random DOM trees with mixed stable / autogen ids and class soup, assert the engine always emits a selector that uniquely resolves back to the target
Design references
- Design doc:
packages/plugin-autocapture-browser/element-selector-strategy-v1-no-classes.md
- Implementation plan: phased PR breakdown in the design doc's "Rollout" section