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.
ISednaUi.FollowSpotlightAsync(hole, target, options) — in JavaScript,
sednaUi.spotlight.follow — keeps the hole and the bubble attached while the
page moves under them: scroll, resize, and the case neither of those covers, a re-render
shifting the DOM with no event at all. It returns a SednaSpotlight the
component owns and disposes. The hole, the bubble and the steps are your markup and your
state; this only positions them. The hole is placed against its offset parent, so give the
stage position: relative, and call UpdateAsync() after every
render rather than only on a step change: the bubble's own size decides which side it
fits on.
@inject ISednaUi Ui
@implements IAsyncDisposable
<div class="sedna-col">
<div id="follow-stage" style="position:relative; height:420px; overflow:hidden;
border:1px solid var(--border); border-radius:var(--radius-surface)">
<div class="sedna-col" style="padding:16px">
<div class="toolbar">
<div class="toolbar-filters">
@* .menu-anchor is what attaches the results to the box. Without it a bare
.menu keeps a dropdown's whole appearance and none of its attachment:
it lands wherever the flow puts it, which is below the toolbar's own
margin and 60px wider than the field. --start aligns it to the leading
edge, because this box is at the leading end of the row. *@
<span class="menu-anchor">
<input class="form-input toolbar-input toolbar-input--wide" type="search"
placeholder="Search shipments…" data-live="@Live(0)" />
@* Step 1 unions the box with the results it produced, so the hole
covers both. *@
<div class="menu menu--start" data-live-extra="@Live(0)"
style="display:@(_step == 0 ? "block" : "none")">
<span class="menu-item">SHP-4471 — Rotterdam, held</span>
</div>
</span>
</div>
<button class="btn btn-warn" type="button" data-live="@Live(1)">Hold selected</button>
</div>
@* Clear of the results panel above, which is fixed and overlays rather than
pushing — without the room, step 1's panel half-covers this row. *@
<div class="stat-row stat-row--divided" data-live="@Live(2)" style="margin-top:36px">
<span class="stat">
<span class="stat-label">Held</span>
<span class="stat-value">6</span>
</span>
<span class="stat">
<span class="stat-label">Released today</span>
<span class="stat-value">31</span>
</span>
</div>
</div>
<div id="follow-hole" class="spotlight-hole spotlight-ring"></div>
<div id="follow-tip" class="spotlight-tip">
<div class="spotlight-tip-head">
<span class="spotlight-tip-title">@Steps[_step].Title</span>
<button class="spotlight-tip-exit" type="button" @onclick="Restart">Start over</button>
</div>
<div class="spotlight-tip-body">@Steps[_step].Body</div>
<div class="spotlight-tip-actions">
<span class="spotlight-step">@(_step + 1) of @Steps.Length — @_side</span>
<span class="sedna-row sedna-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" disabled="@(_step == Steps.Length - 1)"
@onclick="() => Go(1)">Next</button>
</span>
</div>
</div>
</div>
</div>
@code {
private sealed record Step(string Title, string Body);
private static readonly Step[] Steps =
[
new("The box and what it found", "One step often has to reveal a control and the UI it produced. `include` widens the hole over the results without making them the anchor."),
new("Hold, do not cancel", "A hold keeps the shipment's slot for 24 hours. `placement: 'auto'` puts this bubble on whichever side has the most room, and the step counter reports which one it chose."),
new("Today against the target", "Both figures update as shipments move. A dash means the endpoint did not answer — never a zero."),
];
private SednaSpotlight? _tour;
private int _step;
private string _side = "…";
// The anchor is named by selector, not held as an ElementReference: the attribute moves
// to the current step's element and follow() re-resolves it on every placement. That is
// also what survives a re-render replacing the node.
private string? Live(int step) => _step == step ? "yes" : null;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_tour = await Ui.FollowSpotlightAsync("#follow-hole", "[data-live]", new SpotlightOptions
{
Pad = 6,
Include = "[data-live-extra]",
Tip = "#follow-tip",
Placement = SpotlightPlacement.Auto,
// This demo is a box on a page, so the box stands in for the viewport.
// A tour that runs across the whole page leaves Boundary and Root unset.
Boundary = "#follow-stage",
Root = "#follow-stage",
});
}
// After every render, because the bubble's own size decides which side it fits on
// and that is known only once this step's text has been laid out in it.
await Update();
}
private async Task Go(int by)
{
_step = Math.Clamp(_step + by, 0, Steps.Length - 1);
await Update();
}
private async Task Restart()
{
_step = 0;
await Update();
}
private async Task Update()
{
if (_tour is null) return;
var side = await _tour.UpdateAsync();
if (side is not null && side != _side)
{
_side = side;
StateHasChanged();
}
}
// Ends the step: detaches the listeners and the observer, releases any lock, and hides
// the hole. The handle is the app's, so ending it is the app's job.
public async ValueTask DisposeAsync()
{
if (_tour is not null) await _tour.DisposeAsync();
}
}
Positioning it
Every element may be given as a CSS selector instead of an element, and for anything that
outlives one render that is the form to prefer — a framework replaces the node, and a
reference captured once then points at a detached copy. It is also why the C# options take
selectors rather than an ElementReference.
// Every element below may be an element or a CSS selector. Prefer the selector
// for anything that outlives one render: a framework replaces the node, and a
// reference captured once then points at a detached copy.
// 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 twice —
// or null when nothing visible is left, which is the signal to hide the hole.
const rect = sednaUi.spotlight.at('#hole', '#save'); // 4px of padding
const wide = sednaUi.spotlight.at('#hole', '#save', { pad: 8 });
// A step that reveals a control AND what it produced: the hole covers the union.
// `include` widens the box without making those elements the anchor, and an
// element the framework has rendered but not shown yet is skipped.
sednaUi.spotlight.at('#hole', '#search', { include: '[data-results]' });
// Place the bubble beside that rectangle. It flips to the opposite side when the
// one asked for does not fit, is clamped so it stays whole, and reports the side
// it used so a caller can point an arrow.
sednaUi.spotlight.tipAt('#tip', rect); // below
sednaUi.spotlight.tipAt('#tip', rect, 14); // below, wider gap
const side = sednaUi.spotlight.tipAt('#tip', rect, {
placement: 'auto', // or 'top' | 'right' | 'bottom' | 'left'
gap: 14, // distance from the rectangle
margin: 10, // smallest distance from the boundary's edge
boundary: '#stage', // what to stay inside; the viewport by default
});
// Keep both attached while the page moves: scroll (including a nested scroller),
// resize, and the case neither of those covers — a re-render shifting the DOM
// with no event at all.
const step = sednaUi.spotlight.follow('#hole', '[data-live]', {
pad: 6,
include: '[data-live-extra]',
tip: '#tip',
placement: 'auto',
root: '#stage', // what to watch, body by default
lock: { interactive: true }, // see below
});
step.update(); // after the app changes something itself; returns the side used
step.stop(); // end of step: detaches, unlocks, hides the hole
// From C#, with no ElementReference to thread through and no IJSRuntime:
_step = await Ui.FollowSpotlightAsync("#hole", "[data-live]", new SpotlightOptions
{
Tip = "#tip",
Placement = SpotlightPlacement.Auto,
Root = "#stage",
});
await _step.UpdateAsync(); // after every render; returns the side used
await _step.DisposeAsync(); // end of step, and from the component's own DisposeAsync
// The element the hole sits in has to be positioned: the hole is placed against
// its offset parent.
The bubble, in sections
A step in a real tour needs more than a hint does: a way out that is not
“Next”, a body that can run to a paragraph, and a footer that reads as one. Add
.spotlight-tip-head and the bubble drops its own padding so each section
carries its own; .spotlight-tip-do marks a step the reader advances themselves
rather than by pressing Next.
<div style="position:relative; height:280px; overflow:hidden; border:1px solid var(--border); border-radius:var(--radius-surface)">
<div class="sedna-col" style="padding:16px">
<div class="toolbar">
<div class="toolbar-filters">
<input class="form-input toolbar-input" type="search" placeholder="Search shipments…" />
</div>
<button class="btn btn-warn" type="button">Hold selected</button>
</div>
</div>
<div class="spotlight-hole spotlight-ring"
style="top:12px; inset-inline-end:12px; width:122px; height:44px; border-radius:var(--radius-control)"></div>
<div class="spotlight-tip" style="top:68px; inset-inline-end:12px">
<div class="spotlight-tip-head">
<span class="spotlight-tip-title">Hold, do not cancel</span>
<button class="spotlight-tip-exit" type="button">Skip tour</button>
</div>
<div class="spotlight-tip-body">
A hold keeps the shipment's slot for 24 hours. Cancelling releases it to the next
order in the queue, and <strong>that cannot be undone</strong>.
</div>
<div class="spotlight-tip-actions">
<span class="spotlight-step">3 of 7</span>
<span class="spotlight-tip-do"><i class="ri-cursor-line"></i> Press it</span>
</div>
</div>
</div>
Making the rest of the page inert
lock() takes pointer input and Enter/Space activation away from everything
except the bubble and, when the step needs it, the anchor, and it gates hover hints too;
scrolling and typing stay free. It is security-shaped and is not
security: capture-phase guards and
pointer-events: none stop a person, not a script, so never treat a live step
as an authorization boundary.
// While a step is live, the page stops accepting input except where the step
// needs it. `.spotlight-lock` goes on <body>, and capture-phase guards back the
// CSS up — neither is a security boundary, and neither should ever be treated as
// one. A person is stopped; a script is not.
sednaUi.spotlight.lock();
sednaUi.spotlight.unlock();
// Usually it is not called directly: follow() takes it, so one handle owns the
// step's geometry and its input model, and stop() releases both.
const step = sednaUi.spotlight.follow('#hole', '[data-live]', {
tip: '#tip',
lock: {
interactive: true, // the reader may act on the anchor itself
allow: '#components-reconnect-modal',// anything else that stays usable
block: '[data-spotlight-block]', // the opt-out, this is the default
},
});
// What stays usable, always: the bubble, whatever carries `.spotlight-allowed`
// (follow() puts it on the anchor when `interactive` is set, and re-applies it on
// every placement because a re-render drops it), and Blazor's reconnect UI — a
// dropped circuit has to be recoverable mid-step.
// What stays free: scrolling, and typing. Only pointer input and Enter/Space
// activation are taken away, so the space bar still writes a space in a text box.
// The opt-out is for a control INSIDE the live anchor that the step must not let
// the reader press — the Cancel beside the button the step is about.
<div class="btn-group" data-live="yes">
<button class="btn btn-warn" type="button">Hold shipment</button>
<span data-spotlight-block>
<button class="btn" type="button">Cancel</button>
</span>
</div>
// Hover hints are gated by the lock itself: a control the reader cannot press
// must not still explain itself. An app's own `sednaUi.tips.gate` is chained
// rather than replaced, and handed back by unlock().
Over an open modal dialog
A step can point at a control inside a <dialog> opened with
showModal(), and nothing extra is called: the hole and the bubble are raised
into the top layer after the dialog, and the bubble is moved into it for as long as the step
is there. Both halves are needed — an open modal dialog is in the top layer,
which no z-index reaches, and it makes everything outside it inert, so a bubble left in the
page paints underneath it and stops answering clicks.
Escape, the focus trap and the inert background are untouched, and both are put
back exactly where they were when the step moves on or the dialog closes. The one thing that
cannot be done is allowing something outside the dialog: the platform has made it
inert, and lock() cannot undo that.
// A step can point INSIDE a <dialog> opened with showModal(), and there is nothing
// extra to call: at() and follow() see that the target is in one, raise the hole and
// the bubble into the top layer after the dialog, and move the bubble into it. Both
// go back exactly where they were when the step moves on or the dialog closes.
sednaUi.modal.show('new-api-key'); // or await Ui.ShowModalAsync("new-api-key")
const step = sednaUi.spotlight.follow('#hole', '#key-name', {
tip: '#tip',
lock: { interactive: true },
});
// Why it has to be done at all: an open modal dialog is in the top layer, which no
// z-index reaches, AND the platform makes everything outside it inert. A bubble left
// in the page therefore paints under the dialog and stops answering — a real click on
// Next lands on the dialog, and the keyboard cannot reach it either.
// What follows from that:
//
// · Only what is INSIDE the dialog can be allowed. lock({ allow: '#help' }) naming
// something on the page behind cannot bring it back; the platform made it inert.
// · A .menu or a .popover opened inside the dialog sits UNDER the dim, where on the
// page it would sit above it. Widen the hole over it with `include`.
// · Escape still closes the dialog and Tab still cycles inside it. The bubble joins
// that cycle, because it is in the dialog while the step is live.
// · The bubble is a node your framework owns, so keep it last among its siblings:
// one inserted immediately before it while it is away lands in the dialog. A
// bubble you render inside the dialog yourself is raised and never moved.
<dialog class="modal" id="new-api-key">
<div class="modal-header"><h3>New API key</h3></div>
<div class="modal-body">
<div class="form-field">
<label class="form-label" for="key-name">Name</label>
<input class="form-input" id="key-name" />
</div>
</div>
</dialog>
<div id="hole" class="spotlight-hole spotlight-ring"></div>
<div id="tip" class="spotlight-tip">
<span class="spotlight-tip-title">Name it after the job</span>
A key named for what uses it is a key somebody can revoke without asking around.
<div class="spotlight-tip-actions">
<span class="spotlight-step">3 of 5</span>
<button class="btn btn-sm btn-primary" type="button">Next</button>
</div>
</div>
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, and drops
.spotlight-step, because a counter reading “1 of 1” promises more.
<div style="position:relative; height:230px; 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:8px; inset-inline-end:8px; width:44px; height:44px; border-radius:var(--radius-control)"></div>
<div class="spotlight-tip" style="top:64px; inset-inline-end:8px; 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 it carries
pointer-events: none so the highlighted control stays operable;
.spotlight-ring outlines that box, .spotlight-tip is the bubble,
.spotlight-tip-actions its footer and .spotlight-step the
position within the sequence. The rectangle on the hole, its radius — the target's own, so
a pill is not highlighted with a rounded box — and the two values on the tip are the only
things an app sets, and they are what at() and tipAt() write.
<div style="position:relative; height:240px; 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:10px; left:10px; width:109px; height:44px; border-radius:var(--radius-control)"></div>
<div class="spotlight-tip" style="top:66px; left:10px">
<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>