APOLLO VISION LABS

Hotspots

A marker on one element that opens a short explanation and can start a tour, what retires it for good, and when it draws nothing at all.

A hotspot is a small marker pinned to one element, outside any tour. Clicking it opens a bubble with a title, a body, and, optionally, a button that starts a tour. Opening the bubble marks the hotspot seen, for good: a seen hotspot draws no marker again, on this page or the next visit, until reset() is called.

Hotspot and ResolvedHotspot

interface Hotspot {
  id: string;
  target: string;
  title?: string;
  titleKey?: string;
  body?: string;
  bodyKey?: string;
  tourId?: string;
  placement?: 'top' | 'bottom' | 'left' | 'right';
}
  • target: the logical key carried by the element’s data-guide attribute, matched the same way a tour step’s target is.
  • title / body: literal text. titleKey / bodyKey resolve through the provider’s translate instead, exactly as a tour step does.
  • tourId: the tour started when the bubble’s “Show me” button is clicked. Omit it and the bubble shows only a Close button.
  • placement: overrides the default bubble placement for this hotspot only.

Two hotspots sharing an id make HotspotProvider throw at render, immediately, the same as a duplicate tour id.

useHotspots() hands back each hotspot resolved to a ResolvedHotspot, with its text already translated and its seen state attached:

interface ResolvedHotspot {
  id: string;
  target: string;
  title: string;
  body: string;
  seen: boolean;
  tourId?: string;
  placement?: 'top' | 'bottom' | 'left' | 'right';
}

HotspotProvider and useHotspots

import {
  HotspotProvider,
  createBrowserStorage,
  type Hotspot,
} from '@apollovisionlabs/guide-core';
import { Hotspots } from '@apollovisionlabs/guide-mui';

const storage = createBrowserStorage('my-app');

const hotspots: Hotspot[] = [
  { id: 'share', target: 'project.share', title: 'Share a project', tourId: 'sharing' },
];

export function App() {
  return (
    <HotspotProvider hotspots={hotspots} storage={storage}>
      <AppRoutes />
      <Hotspots />
    </HotspotProvider>
  );
}
Prop Type Default Description
hotspots Hotspot[] none The hotspots available in this tree. Ids must be unique.
children ReactNode none Your application.
storage GuideStorage none Persists which hotspots have been opened, under the single key hotspots:seen. See Persistence.
translate (key: string) => string none Resolves titleKey / bodyKey.
onEvent (event: GuideEvent) => void none Called for hotspot:show and hotspot:open. See “Events” below.

useHotspots() returns:

interface UseHotspotsResult {
  hotspots: ResolvedHotspot[];
  restored: boolean;
  open: (hotspotId: string) => void;
  startTour: (hotspotId: string) => void;
  reset: () => void;
  notifyShown: (hotspotId: string) => void;
}
  • hotspots: every hotspot, seen and unseen alike, each carrying its own seen. A renderer needs the seen ones too, to keep a marker mounted while its own bubble closes; filtering to the unseen ones is one line at the call site if that is all you want.
  • restored: whether the initial read from storage has settled. true immediately when no storage prop was given, since there is nothing to wait for, and true once the read resolves or rejects otherwise, including a failed read. A renderer should wait for this before drawing any marker, or a hotspot already seen in storage can flash on screen once before the restore lands.
  • open(hotspotId): marks the hotspot seen and emits hotspot:open. Calling it again on a hotspot already seen does nothing further. Logs a warning and does nothing for an id that names no hotspot.
  • startTour(hotspotId): starts the hotspot’s tourId on the nearest GuideProvider. Does nothing if the hotspot has no tourId. Warns once if the tree has no GuideProvider, and warns once if the tour fails to start.
  • reset(): clears every seen hotspot at once, bringing every marker back. There is no per-hotspot undo.
  • notifyShown(hotspotId): called by a renderer once a marker is actually drawn on screen. Drives hotspot:show, and is deduplicated per hotspot per mount, so a scroll that re-measures the target does not announce a second impression. Hotspots already calls this for you; write it yourself only if you render your own markers on top of useHotspots.

Opening retires a hotspot, permanently

open(hotspotId), which the shipped marker calls when it is clicked, is the only thing that sets seen to true. Once it has run for a hotspot, that hotspot’s ResolvedHotspot.seen is true everywhere useHotspots() is read, and, with storage configured, it stays true across reloads because it is written under hotspots:seen. The Hotspots component renders nothing for a seen hotspot. The only way back is reset(), and it takes every hotspot with it: there is no way to un-see one hotspot on its own.

The MUI Hotspots component

import { Hotspots } from '@apollovisionlabs/guide-mui';

<Hotspots labels={{ startTour: 'Show me', close: 'Close' }} placement="bottom" />;
Prop Type Default Description
labels Partial<HotspotLabels> see below The marker’s accessible name and the bubble’s button text.
placement 'top' | 'bottom' | 'left' | 'right' 'bottom' Default bubble placement; a hotspot’s own placement wins.
zIndex number theme.zIndex.drawer + 1 Stacking level of the marker; the bubble sits one above it. See “Limits” below for why this can matter.

HotspotLabels.marker is a function

interface HotspotLabels {
  marker: (title: string) => string;
  startTour: string;
  close: string;
}

Defaults: marker: (title) => \Show what is new: ${title}`, startTour: ‘Show me’, close: ‘Close’`.

marker builds the marker button’s accessible name, and it is a function rather than a template string because where the title falls in the sentence is not the same from one language to the next: a translation may need to put the title first, wrap it differently, or drop the leading phrase altogether. startTour and close are plain strings, the same as the rest of labels on GuideTour and Checklist.

Events

Event Payload When
hotspot:show { hotspotId } A hotspot’s marker is actually drawn on screen. Emitted once per hotspot per mount.
hotspot:open { hotspotId } The hotspot’s bubble is opened, which also marks it seen. Not emitted again for a bubble that is already open; a second click on an open marker just closes it.

Hotspots defer to a running tour

While a tour is running or paused, Hotspots draws no markers at all, not only the ones that collide with the current step’s target. A marker sits position: fixed over its target’s top-right corner, so when a tour step points at that same element, the marker can intercept the click meant to advance an advanceOn: 'click' step, sit pulsing uselessly on top of a non-interactive step’s spotlight, or hold keyboard focus over the step that a tour just launched from its own bubble. Rather than resolve each of those collisions in place, no hotspot marker draws while a tour is live.

paused counts as live: a paused tour is waiting for its target, not finished. This is suppression, not retirement. Nothing here touches seen, so markers come back exactly as they were, unseen ones included, the moment the tour stops or completes, and hotspot:show fires again for them since its deduplication is per mount, not global.

Limits

  • A hotspot inside a modal dialog is covered. The marker’s default zIndex is theme.zIndex.drawer + 1, chosen to sit above an app bar or a drawer, where a hotspot’s target often is, and below a running tour’s spotlight at theme.zIndex.modal. A dialog also renders above the drawer layer, so a hotspot whose target lives inside one is covered by the dialog unless zIndex is raised past it.
  • With storage configured, no marker draws until the initial read settles. Hotspots waits for restored before rendering anything, so on a slow storage backend the first paint has no markers at all, and they appear once the read resolves.
  • A tour paused on a target that never mounts suppresses every hotspot for as long as it stays paused. Under the wait missing-target policy, a paused tour already draws nothing itself; with hotspots deferring to it too, the page can end up with no visible affordance at all, Escape being the only way out.
  • Tours for tourId, the missing-target policies, and what paused means.
  • Persistence for GuideStorage and the merge behaviour on restore.
  • Migrating to 0.3.0 for what changed when hotspots were added.
  • API reference for every exported symbol.