Skip to content

Targeting elements

Spotlight points a tour step, spotlight, tooltip, or hotspot at a DOM element. This page is about how the SDK finds that element at runtime, and how you as the host-app author make it findable without bloating your pages.

Why it matters

Bad targeting is the cause of 90% of tours breaking after a release. An author clicks the "Save" button in the admin picker; the SDK captures div.flex.flex-1 > button:nth-of-type(3); two weeks later somebody adds a "Help" button above Save and the tour silently breaks.

The fix is the same fix teams use for test automation: give the elements you care about a stable, opinionated anchor.

The priority ladder

When the admin picker captures an element, it walks this ladder top-down and stops at the first hit. At runtime, the SDK uses the same ladder to resolve. So the ladder is the contract you're designing against.

  1. data-spotlight="…" — opt-in, wins over everything.
  2. Your team's test-hook attributedata-testid, data-qa, data-cy, whatever convention the test suite already uses. The picker's Settings → Selectors tab controls the exact list and order.
  3. A stable HTML id (see "What counts as stable" below).
  4. aria-label on an interactive element.
  5. name= on a form element.
  6. role= when unique on the page.
  7. title= on the target.
  8. A structural selector — anchored on the nearest ancestor with one of (1)–(7), then tag + :nth-of-type() below that.

The one rule

Use what your app already has. Don't add data-spotlight unless nothing else reaches the element.

Spotlight is a drop-in. It shouldn't require you to sprinkle proprietary attributes across your components. Most of the time you already have something stable — a test-hook, a route id, an accessible name — and Spotlight should find it.

Best practice, in order of preference

1. Lean on existing test hooks

If your team uses Cypress (data-cy), Testing Library (data-testid), or Playwright (data-pw), the same attributes double as Spotlight targets. No new attributes, no bloat, and the test suite already guarantees they stay put.

The admin's Settings → Selectors tab lets each tenant pick the order Spotlight checks these in. Default priority is data-spotlight, data-testid, data-test, data-test-id, data-cy, data-qa, data-qa-id, data-automation-id.

html
<button data-testid="payments-send">Send</button>

Picker captures [data-testid="payments-send"]. Refactor the class list, move the button around, doesn't matter — the selector stays.

2. Use semantic IDs on big landmarks

Unique page landmarks — a main form, a primary table, a payment wizard, a checklist — deserve a real id. Spotlight treats these as anchors and builds any structural descendants from them, so even non-anchored children inherit stability.

html
<main id="request-payment-wizard">
  <section>
    <h2>Step 1 — pick an account</h2>
    <table>…</table>
  </section>
</main>

A click on the first <td> captures #request-payment-wizard table tr:nth-of-type(1) > td:nth-of-type(1). Anyone rearranging the table heads still leaves the anchor alone, so the selector holds.

The rule: if you can point at a visually-meaningful block and say "this thing has a name", give it an id. Everything below inherits that name as context.

3. Use aria-label on interactive controls

Buttons, links, and form inputs that do one thing usually have an aria-label already (for screen readers). Spotlight uses it.

html
<button aria-label="Open settings">
  <svg>…</svg>
</button>

Captures as button[aria-label="Open settings"] — both accessible and stable.

4. Name your form fields

Every <input> / <select> / <textarea> in a form should have name=. Spotlight uses this directly.

html
<input name="email" type="email" />

Captures input[name="email"].

5. Only add data-spotlight as a last resort

If an element has no id, no test hook, no aria-label, and no name, and you still need Spotlight to target it reliably, then add a data-spotlight:

html
<div data-spotlight="marketing-cta-hero">

Reserve it for elements that genuinely lack any other identifier — a decorative marketing block, say. Don't default to it for every tour step; the whole point is to avoid a proprietary attribute on every page.

What counts as a "stable" id

Spotlight's picker rejects ids that look autogenerated:

  • headlessui-menu-button-42
  • radix-#_r37
  • react-:r5:
  • Anything with 8+ hex digits at the end (uuid-ish)
  • Anything prefixed : (React 18 automatic ids)

If your framework emits those, either wrap the element with a parent that has a real id, or add one of the test-hook attributes from tier 2.

What counts as a "landmark"

The picker treats these tags as anchor-worthy even without an explicit attribute, provided they're unique on the page:

  • <main>, <nav>, <header>, <footer>, <aside>
  • <section>, <article>
  • <form>, <table>, <dialog>
  • <ol>, <ul>

So: if your page wraps major regions in semantic HTML (you should be doing this anyway for a11y), Spotlight gets anchors for free.

A concrete example

Before — no anchors, picker ends up with a brittle chain:

html
<div class="min-h-screen bg-gray-50">
  <div class="lg:pl-64">
    <main class="flex flex-col">
      <div class="flex-1 flex">
        <div class="w-full flex-1">
          <table class="min-w-full w-full">
            <tbody>
              <tr class="hover:bg-zinc-100">
                <td class="sticky h-16">Select an account</td>

Picker captures:

tr:nth-of-type(1) > td:nth-of-type(1)

Works today; snaps the moment a second <table> shows up anywhere.

After — one id on the wizard, nothing else changes:

html
<main id="request-payment-wizard" class="flex flex-col">
  <table>
    <tr><td>Select an account</td></tr>

Picker captures:

#request-payment-wizard table > tbody > tr > td:nth-of-type(1)

That's safe against every DOM change outside the wizard — and most changes inside it too. One attribute on one element buys stability for every tour step below.

Diagnosing a broken selector

Admin overlay → open the step editor. The target-summary chip above the selector input turns:

  • indigo — one match, we're good
  • amber — more than one match; tighten the selector
  • rose — zero matches; the element isn't on the current page, or the selector no longer resolves

Amber is the early-warning signal. Don't ignore it.

Performance and bloat

The SDK runtime doesn't add anything to your page. Targeting attributes you've added yourself are free — they're just attributes. What we won't do:

  • Generate data-spotlight attributes at build time.
  • Run MutationObserver on the whole document.
  • Query-select on every frame.

A tour step resolves its target once at start and once more if the element unmounts mid-tour. Static pages cost nothing.

Spotlight