Spotlight
tier 2 — classesDims the page except one element, for a guided tour or a first-run hint.
A three-step tour
Live — step through it. The hole moves to each target, takes that target's own border radius, and the bubble flips above when there is no room below.
drSimpleUi.spotlight.at(hole, target) measures the target and writes the hole's
four values; tipAt(tip, rect) places the bubble. There is no tour
API. The steps, the copy, the order and what “next” means are the app's —
a library that owned them would also own whether a step can be skipped and where the bubble
goes, and those differ per app.
The hole is positioned against its offset parent, so give the stage
position: relative. Position it after every render, not only on a step change:
the bubble's own size decides where it fits, and that is known only once it has been laid out
with this step's text in it.
@inject IJSRuntime Js
<div class="dr-col">
<div @ref="_stage" style="position:relative; height:260px; overflow:hidden;
border:1px solid var(--border); border-radius:var(--radius-surface)">
<div class="dr-col" style="padding:16px">
<div class="dr-row dr-between">
<button @ref="_targets[0]" class="btn btn-primary" type="button">Reconcile all</button>
<span @ref="_targets[1]" class="health-badge health-badge--live">
<span class="health-dot"></span> Live
</span>
</div>
<div @ref="_targets[2]" class="stat-row stat-row--divided">
<span class="stat">
<span class="stat-label">Dispatched</span>
<span class="stat-value">248</span>
</span>
<span class="stat">
<span class="stat-label">Backordered</span>
<span class="stat-value">12</span>
</span>
</div>
</div>
<div @ref="_hole" class="spotlight-hole spotlight-ring"></div>
<div @ref="_tip" class="spotlight-tip">
<span class="spotlight-tip-title">@Steps[_step].Title</span>
@Steps[_step].Body
<div class="spotlight-tip-actions">
<span class="spotlight-step">@(_step + 1) of @Steps.Length</span>
<span class="dr-row dr-gap-1">
<button class="btn btn-sm" type="button" disabled="@(_step == 0)"
@onclick="() => Go(-1)">Back</button>
<button class="btn btn-sm btn-primary" type="button"
@onclick="() => Go(1)">
@(_step == Steps.Length - 1 ? "Done" : "Next")
</button>
</span>
</div>
</div>
</div>
</div>
@code {
private sealed record Step(string Title, string Body);
private static readonly Step[] Steps =
[
new("Reconcile everything at once", "Applies every pending reservation above the confidence threshold. Each one can still be undone afterwards."),
new("Watch the connection", "Pulsing means the stream is live. It stops pulsing the moment the connection settles or drops."),
new("Today against the target", "Both figures update as orders move. A dash means the endpoint did not answer — never a zero."),
];
private ElementReference _stage;
private ElementReference _hole;
private ElementReference _tip;
private readonly ElementReference[] _targets = new ElementReference[Steps.Length];
private int _step;
// After every render, because the tip's own size decides where it fits and that is
// only known once it has been laid out with this step's text in it.
protected override async Task OnAfterRenderAsync(bool firstRender) => await Place();
private async Task Go(int by)
{
_step = Math.Clamp(_step + by, 0, Steps.Length - 1);
await Place();
}
private async Task Place()
{
// The helper measures the target and writes the hole's four values; the app owns
// which target, the copy and the order. That split is why there is no tour API.
var rect = await Js.InvokeAsync<object>("drSimpleUi.spotlight.at", _hole, _targets[_step]);
await Js.InvokeVoidAsync("drSimpleUi.spotlight.tipAt", _tip, rect, 12);
}
}
A first-run hint
One element, one bubble, no step counter — the case a tour is too much for. Everything here is
static markup with the hole placed by hand, so it renders without scripting; an app that knows
the target only at runtime calls at() instead.
Drop .spotlight-step when there is only one step: a counter reading
“1 of 1” promises more.
<div style="position:relative; height:180px; overflow:hidden; border:1px solid var(--border); border-radius:var(--radius-surface)">
<div class="toolbar" style="margin:12px">
<div class="toolbar-filters">
<input class="form-input toolbar-input" type="search" placeholder="Search orders…" />
</div>
<button class="btn btn-icon" type="button" aria-label="Saved views"><i class="ri-bookmark-line"></i></button>
</div>
<div class="spotlight-hole spotlight-ring"
style="top:20px; inset-inline-end:20px; width:36px; height:36px; border-radius:var(--radius-control)"></div>
<div class="spotlight-tip" style="top:70px; inset-inline-end:20px; max-width:250px">
<span class="spotlight-tip-title">Saved views live here</span>
Whatever you have filtered to can be kept and reopened later. Your views are yours alone.
<div class="spotlight-tip-actions">
<button class="btn btn-sm btn-primary" type="button">Got it</button>
</div>
</div>
</div>
The markup
.spotlight-hole is the dim with the un-shadowed box in it, and
.spotlight-ring outlines that box. .spotlight-tip is the bubble;
.spotlight-tip-actions is its footer, and .spotlight-step the
position within the sequence.
The four values on the hole and the two on the tip are the only things an app sets, and they
are what at() and tipAt() write. Everything else is in the
stylesheet.
<div style="position:relative; height:200px; overflow:hidden; border:1px solid var(--border)">
<div style="padding:14px">
<button class="btn btn-primary" type="button">Approve all</button>
<p class="form-hint" style="margin-top:10px">Page content behind the dim.</p>
</div>
<div class="spotlight-hole spotlight-ring"
style="top:14px; left:14px; width:132px; height:33px"></div>
<div class="spotlight-tip" style="top:60px; left:14px">
<span class="spotlight-tip-title">Approve in one go</span>
Applies every reservation above the confidence threshold. You can still undo each one
afterwards.
<div class="spotlight-tip-actions">
<span class="spotlight-step">2 of 5</span>
<button class="btn btn-sm btn-primary" type="button">Next</button>
</div>
</div>
</div>
Positioning it
at() returns the rectangle it used, so tipAt() places the bubble
without measuring the target twice. Call both after every render: the bubble's own
size decides where it fits, and that is known only once it has been laid out with this step's
text in it.
// Position the hole over a target. Returns the rectangle it used, in the
// hole's own coordinate space, so a bubble can be placed without measuring
// the target twice.
const rect = drSimpleUi.spotlight.at(hole, target); // 4px of padding
const wide = drSimpleUi.spotlight.at(hole, target, { pad: 8 });
// Place the bubble under that rectangle, flipped above when the viewport has
// no room below.
drSimpleUi.spotlight.tipAt(tip, rect);
drSimpleUi.spotlight.tipAt(tip, rect, 14); // a wider gap
// From Blazor. There is no IDrSimpleUi member for this: both calls take
// element references, which only IJSRuntime can pass. Position after every
// render, not only on a step change — the bubble's own size decides where it
// fits, and that is known only once it has been laid out with this step's
// text in it.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var rect = await Js.InvokeAsync<object>("drSimpleUi.spotlight.at", _hole, _targets[_step]);
await Js.InvokeVoidAsync("drSimpleUi.spotlight.tipAt", _tip, rect, 12);
}
// The element the hole sits in has to be positioned: the hole is placed
// against its offset parent.
box-shadow with a 100vmax spread rather than a clip-path or
four strips: one element, no arithmetic, and the ring follows the highlighted element's own
corners. pointer-events: none on it, so the highlighted control stays operable — a
tour that blocks the thing it is pointing at teaches nothing. z-index 510 is the rung between the
modal backdrop and the dropdown band, so a spotlight can explain a modal and still be covered by
a menu opened inside what it highlights.