Analytics has a habit of leaking implementation details.
You ship data-track-name.
You ship btn-324.
You ship “modal-close-x”.
Then someone refactors the UI and your events turn into archaeology.
I ran into this while working on click tracking. At the same time, we were pushing hard on accessibility. I wanted one approach that helped both goals.
That is when I found a better primitive.
Use the same thing assistive tech uses.
Use the accessible name.
The problem
Typical click tracking reaches for IDs or custom attributes.
window.addEventListener("click", (event) => { console.log(event.target.id); // "btn-324" or "login-button"});Or it hardcodes selectors and event names.
<script> window.addEventListener("load", () => { document.querySelectorAll(".download-link").forEach((item) => { item.addEventListener("click", () => { fathom.trackEvent("file download"); }); }); });</script>Or it bakes analytics into markup.
<button class="plausible-event-name=Button+Click">Click Me</button>All of these “work”.
They also create the same long term failure mode.
- IDs change during refactors
- attributes get out of sync with the UI
- event names describe code, not intent
- developers carry the maintenance burden forever
Look at the difference in logs.
"btn-324", "modal-close-x", "nav-item-2", "cta-primary""Search products", "Close dialog", "About us", "Start free trial"One tells you how the UI is built.
The other tells you what the user did.
So how do you get the second set without hand-labeling everything twice?
The better source of truth
Accessible names come from the accessibility tree.
Screen readers use them. Testing Library uses them. They come from standard rules, not your naming conventions.
Accessible names are computed from places like:
aria-labelaria-labelledby<label>elements- visible text content
altattributes
Example:
<button aria-label="Search the site"> <svg aria-hidden="true"></svg></button>The computed accessible name is Search the site.
That name is:
- user-facing
- stable across refactors
- already part of the UI contract
Testing Library has a line that stuck with me:
The more your tests resemble the way your software is used, the more confidence they can give you.
Analytics should follow the same principle.
If a user clicks “Search”, your analytics should say “Search”.
Not “btn-324”.
The missing piece
Testing Library computes accessible names using dom-accessibility-api.
That library exposes what we need:
computeAccessibleName(element) -> string
So the approach is simple:
- Listen for clicks
- Compute the accessible name for the clicked element
- Send that string to your analytics tool
Examples
This works with anything. Vanilla JS, React, Astro, Vue, Svelte.
It is not framework-specific. It is DOM-specific.
React
import { useEffect } from "react";import { computeAccessibleName } from "dom-accessibility-api";
export default function App() { useEffect(() => { function handleClick(event: MouseEvent) { if (!(event.target instanceof Element)) return;
const name = computeAccessibleName(event.target); if (!name) return;
fathom.trackEvent(`${name} clicked`); }
window.addEventListener("click", handleClick); return () => window.removeEventListener("click", handleClick); }, []);
return <button>Search</button>;}Astro
---import { computeAccessibleName } from "dom-accessibility-api";---
<html lang="en"> <head> <script> window.addEventListener("load", () => { function handleClick(event) { if (!(event.target instanceof Element)) return;
const name = computeAccessibleName(event.target); if (!name) return;
fathom.trackEvent(`${name} clicked`); }
window.addEventListener("click", handleClick); }); </script> </head> <body> <button>Search</button> </body></html>This only works if your UI is labeled. If computeAccessibleName returns an empty string, that is not an analytics bug. That is an accessibility bug.
That is part of the point.
Performance
This looks scary until you remember when it runs.
It runs on clicks.
Not on render. Not on scroll. Not on a timer.
In practice, computeAccessibleName does some DOM traversal, then returns a string. That cost is usually noise compared to the rest of an interaction.
If you do have a high-volume UI with rapid repeated clicks, you can add small optimizations:
- cache by element reference
- ignore empty names
- avoid tracking clicks that bubble from non-interactive targets
- normalize names and cap length
You probably do not need any of that on day one.
Why this approach matters
This gives you benefits that are hard to get any other way.
- Resilient: You track UI semantics, not selectors.
- User-focused: Your data matches what people see.
- Framework-agnostic: Anything that renders DOM works.
- Self-auditing: Missing labels show up as missing events.
- Better collaboration: PMs and designers can read the logs without a decoder ring.
It also surfaces real issues.
If your click logs show empty strings, you have unlabeled controls.
If your logs show five different “Submit” clicks, you have duplicate names that need context.
One fix is aria-labelledby tied to nearby content.
Instead of “Edit”, you can get “Edit Project Alpha” or “Edit User Profile”.
Now your analytics becomes precise without inventing tracking names.
Closing
Analytics should tell you what users did.
Accessibility already defines the language for that.
So stop tracking implementation details.
Track the accessible name.
You get cleaner data, fewer refactor breakages, and a UI that is harder to ship unlabeled.
That is a real win-win.