Cut the active element out of the hint overlay

The overlay is now the same spotlight the tour draws: an svg dim with a
rounded cutout around the open hint's element (the path generator moves
out of the tour's context into a pure function both share). The element
stays visible and interactive inside the cutout — the svg lets pointer
events through and only the dimmed path catches them, so clicking the
dim still closes the hint.

The other beacons now wait under the overlay instead of floating above
it; only the open hint's beacon (via its aria-expanded state) and its
popover rise above. The cutout tracks the element on scroll and resize.

The arrow's drop-shadow halo is gone: it read as a dirty outline against
light pages — tours never needed one because the arrow sits against the
dark overlay, and hints now either touch the colored beacon (no overlay)
or sit against the dim (overlay), so the plain white triangle works in
both.
This commit is contained in:
Kamran Ahmed
2026-07-16 20:42:14 +01:00
parent 2884c76d57
commit 51b66dcfe9
7 changed files with 134 additions and 43 deletions
+6 -6
View File
@@ -16,6 +16,8 @@ ships, these entries move under a versioned heading.
stays interactive. New `driver.js/hints` entry, ~5KB gzip.
[Guide](/docs/hints). ([#363](https://github.com/nilbuild/driver.js/issues/363))
- `--driver-hint-size` and `--driver-hint-color` CSS variables to style the beacons.
- Optional hint overlay (`overlay`, `overlayColor`, `overlayOpacity`) — dims the
page around the open hint's element, tour-style cutout included.
- `sideEffects` field in package.json for better tree-shaking.
### Changed
@@ -73,12 +75,10 @@ ships, these entries move under a versioned heading.
### Changed
- <span class="breaking-badge">Breaking</span> Popover footer buttons are now styled
through the `driver-popover-footer-btn` class instead of the
`.driver-popover-footer button` selector. Buttons you add through `onPopoverRender`
no longer inherit the default styling automatically. Add the
`driver-popover-footer-btn` class to opt in, or leave it off to apply your own
styles. The built-in next/previous buttons carry the class out of the box.
- <span class="breaking-badge">Breaking</span> Popover footer buttons are now styled through the
`driver-popover-footer-btn` class instead of the `.driver-popover-footer button` selector. Buttons you add through
`onPopoverRender` no longer inherit the default styling automatically. Add the `driver-popover-footer-btn` class to
opt in, or leave it off to apply your own styles. The built-in next/previous buttons carry the class out of the box.
([#555](https://github.com/nilbuild/driver.js/issues/555))
- Custom buttons added through `onPopoverRender` now receive their own click
events. Only the built-in next, previous, and close buttons have their default
+5 -3
View File
@@ -126,9 +126,11 @@ Size and colour come from CSS variables:
## Dimming the page
Pass `overlay: true` to dim the page while a hint is open. The beacons and the
popover stay above the overlay, so other hints remain clickable, and clicking
the dimmed page closes the hint like any outside click:
Pass `overlay: true` to dim the page while a hint is open — the hint's element
is cut out of the dim, spotlighting it exactly like a tour step, and it stays
interactive inside the cutout. Everything else, including the other beacons,
sits under the overlay; clicking the dimmed page closes the hint like any
outside click:
```js
const productHints = hints({
@@ -147,8 +147,9 @@ const productHints = hints({
## Dimming the page
With `overlay: true` the page dims while a hint is open — a focus mode for
hints. The beacons stay above the overlay, so you can hop from one hint to the
next; clicking the dimmed page closes the hint.
hints. The hint's element is cut out of the dim and stays interactive, exactly
like a tour step's highlight; the other beacons wait under the overlay.
Clicking the dimmed page closes the hint.
<HintsSample
idPrefix="overlay"
@@ -159,7 +160,7 @@ next; clicking the dimmed page closes the hint.
id: "export",
popover: {
title: "Focus mode",
description: "The page behind is dimmed while you read this. Click the other beacon to hop over.",
description: "The Export button is spotlighted and still clickable; the rest of the page is dimmed.",
},
},
{
@@ -167,8 +168,8 @@ next; clicking the dimmed page closes the hint.
id: "summary",
beacon: { side: "left", align: "center" },
popover: {
title: "Still reachable",
description: "Beacons sit above the overlay, so switching hints never needs the overlay to go away.",
title: "One at a time",
description: "The other beacons wait under the dim. Close this hint to get back to them.",
side: "bottom",
},
},
+16 -12
View File
@@ -12,12 +12,18 @@
width: var(--driver-hint-size, 24px);
height: var(--driver-hint-size, 24px);
cursor: pointer;
/* Below the popover, above the page. */
z-index: 999999998;
/* Above the page but below the overlay, so when a hint is open with the
overlay enabled, the other beacons dim with the rest of the page. */
z-index: 999999996;
/* The beacon is centered on the anchor point set inline by the script. */
transform: translate(-50%, -50%);
}
/* The open hint's beacon rises above the overlay, next to its popover. */
.driver-hint[aria-expanded="true"] {
z-index: 999999998;
}
.driver-hint-hidden {
display: none;
}
@@ -69,20 +75,18 @@
}
}
/* The popover arrow is a white triangle that normally sits against a tour's
darkened overlay. Hints usually have no overlay, so on a light page it
would vanish and the popover would look detached from its beacon — a faint
halo keeps it visible on any background. */
.driver-hint-popover .driver-popover-arrow {
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.25)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
/* The opt-in overlay shown while a hint is open. Beacons and the popover sit
above it, so hints stay clickable while the page behind is dimmed. */
/* The opt-in overlay shown while a hint is open: a full-screen dim with the
hint's element cut out. Only the open hint's beacon and popover sit above
it. The svg itself lets pointer events through (the element in the cutout
stays interactive); the dimmed path catches them, so clicking the dim
closes the hint. */
.driver-hint-overlay {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: 999999997;
pointer-events: none;
animation: driver-hint-overlay-fade 200ms ease-out;
}
+47 -8
View File
@@ -7,10 +7,15 @@ import {
Side,
hidePopover,
} from "./popover";
import { generateStageSvgPathString } from "./overlay";
import { ARROW_CORNER_INSET, ARROW_SIZE, PositionOptions, repositionPopover } from "./position";
import { resolveElement } from "./utils";
import "./hints.css";
// The cutout around the active hint's element, mirroring the tour's defaults.
const OVERLAY_PADDING = 10;
const OVERLAY_RADIUS = 5;
// Part of the hints API surface: beacon/popover placement and the DOM handed
// to onPopoverRender are described with the popover primitive's types.
export type { Alignment, PopoverDOM, Side } from "./popover";
@@ -107,7 +112,7 @@ export function hints(config: HintsConfig = {}): Hints {
let activeId: string | undefined;
let popover: PopoverDOM | undefined;
let overlay: HTMLElement | undefined;
let overlay: SVGSVGElement | undefined;
let refreshTimeout: number | undefined;
let isVisible = false;
@@ -322,16 +327,49 @@ export function hints(config: HintsConfig = {}): Hints {
};
}
function showOverlay() {
function overlayPathFor(element: Element): string {
const rect = element.getBoundingClientRect();
return generateStageSvgPathString(
{ x: rect.x, y: rect.y, width: rect.width, height: rect.height },
{ padding: OVERLAY_PADDING, radius: OVERLAY_RADIUS }
);
}
function showOverlay(entry: MountedHint) {
if (!currentConfig.overlay || overlay) {
return;
}
overlay = document.createElement("div");
overlay.className = "driver-hint-overlay";
overlay.style.backgroundColor = currentConfig.overlayColor || "#000";
overlay.style.opacity = `${currentConfig.overlayOpacity ?? 0.7}`;
document.body.appendChild(overlay);
// A full-screen dim with the hint's element cut out, so what the hint
// talks about stays visible. Pointer events land on the path only: clicks
// on the dim close the hint (via the outside-click listener) while the
// element inside the cutout stays interactive.
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "driver-hint-overlay");
svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
svg.setAttribute("preserveAspectRatio", "xMinYMin slice");
svg.style.fillRule = "evenodd";
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", overlayPathFor(entry.element));
path.style.fill = currentConfig.overlayColor || "#000";
path.style.opacity = `${currentConfig.overlayOpacity ?? 0.7}`;
path.style.pointerEvents = "auto";
svg.appendChild(path);
document.body.appendChild(svg);
overlay = svg;
}
function refreshOverlay(entry: MountedHint) {
if (!overlay) {
return;
}
overlay.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
overlay.firstElementChild?.setAttribute("d", overlayPathFor(entry.element));
}
function removeOverlay() {
@@ -390,7 +428,7 @@ export function hints(config: HintsConfig = {}): Hints {
activeId = entry.id;
entry.beacon.setAttribute("aria-expanded", "true");
showOverlay();
showOverlay(entry);
popover = renderPopover(entry.beacon, popoverOptions(entry));
const onOpen = entry.hint.onOpen || currentConfig.onOpen;
@@ -454,6 +492,7 @@ export function hints(config: HintsConfig = {}): Hints {
}
repositionPopover(popover, active.beacon, popoverPosition(active));
refreshOverlay(active);
// The beacon is gone from the screen, so the popover has nothing to hang
// off; the IntersectionObserver closes it, this just avoids a flash.
+14 -5
View File
@@ -102,7 +102,14 @@ function renderOverlay(ctx: Context, stagePosition: StageDefinition) {
throw new Error("no path element found in stage svg");
}
pathElement.setAttribute("d", generateStageSvgPathString(ctx, stagePosition));
pathElement.setAttribute("d", generateStageSvgPathString(stagePosition, stageOptions(ctx)));
}
function stageOptions(ctx: Context) {
return {
padding: ctx.getConfig("stagePadding") || 0,
radius: ctx.getConfig("stageRadius") || 0,
};
}
function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
@@ -131,7 +138,7 @@ function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
const stagePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
stagePath.setAttribute("d", generateStageSvgPathString(ctx, stage));
stagePath.setAttribute("d", generateStageSvgPathString(stage, stageOptions(ctx)));
stagePath.style.fill = ctx.getConfig("overlayColor") || "rgb(0,0,0)";
stagePath.style.opacity = `${ctx.getConfig("overlayOpacity")}`;
@@ -143,12 +150,14 @@ function createOverlaySvg(ctx: Context, stage: StageDefinition): SVGSVGElement {
return svg;
}
function generateStageSvgPathString(ctx: Context, stage: StageDefinition) {
// The full-screen dim with a rounded cutout, as a single evenodd path. Pure —
// the tour passes its stagePadding/stageRadius, the hints overlay its own.
export function generateStageSvgPathString(stage: StageDefinition, options: { padding: number; radius: number }) {
const windowX = window.innerWidth;
const windowY = window.innerHeight;
const stagePadding = ctx.getConfig("stagePadding") || 0;
const stageRadius = ctx.getConfig("stageRadius") || 0;
const stagePadding = options.padding;
const stageRadius = options.radius;
const stageWidth = stage.width + stagePadding * 2;
const stageHeight = stage.height + stagePadding * 2;
+40 -4
View File
@@ -277,7 +277,8 @@ describe("popover arrow alignment", () => {
});
describe("overlay", () => {
const overlayEl = () => document.querySelector<HTMLElement>(".driver-hint-overlay");
const overlayEl = () => document.querySelector<SVGSVGElement>(".driver-hint-overlay");
const overlayPath = () => overlayEl()?.querySelector("path");
it("shows no overlay by default", () => {
const productHints = createHints({ hints: SAMPLE_HINTS });
@@ -320,8 +321,42 @@ describe("overlay", () => {
productHints.show();
productHints.open("intro");
expect(overlayEl()?.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(overlayEl()?.style.opacity).toBe("0.4");
expect(["#123456", "rgb(18, 52, 86)"]).toContain(overlayPath()?.style.fill);
expect(overlayPath()?.style.opacity).toBe("0.4");
});
it("cuts the hint's element out of the dim", () => {
const rect = (over: Partial<DOMRect>): DOMRect =>
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
const el = document.querySelector<HTMLElement>("#intro")!;
el.getBoundingClientRect = () => rect({ x: 400, y: 300, width: 200, height: 40 });
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
productHints.show();
productHints.open("intro");
// Full-screen subpath plus a cutout subpath. The cutout starts at the
// element's x minus the 10px padding, plus the 5px corner radius: 395.
const d = overlayPath()?.getAttribute("d") ?? "";
expect(d).toContain(`M${window.innerWidth},0L0,0L0,${window.innerHeight}`);
expect(d).toContain("M395,290");
});
it("tracks the element when the page scrolls", async () => {
const rect = (over: Partial<DOMRect>): DOMRect =>
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() {}, ...over }) as DOMRect;
const el = document.querySelector<HTMLElement>("#intro")!;
el.getBoundingClientRect = () => rect({ x: 400, y: 300, width: 200, height: 40 });
const productHints = createHints({ hints: SAMPLE_HINTS, overlay: true });
productHints.show();
productHints.open("intro");
el.getBoundingClientRect = () => rect({ x: 400, y: 100, width: 200, height: 40 });
window.dispatchEvent(new Event("scroll"));
await nextFrame();
expect(overlayPath()?.getAttribute("d")).toContain("M395,90");
});
it("closes the hint when the dimmed page is clicked", () => {
@@ -329,7 +364,8 @@ describe("overlay", () => {
productHints.show();
productHints.open("intro");
overlayEl()!.click();
// Clicks land on the dimmed path (the svg lets pointer events through).
overlayPath()!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(popoverEl()).toBeNull();
expect(overlayEl()).toBeNull();