Use Spotlight for onboarding and tutorial walkthroughs: it focuses the player on one UI element by dimming everything else, then glides the highlight from element to element ("Tap here to start" → Play → Inventory…).
Unlike a per-element UI Effect — which is scoped to its own element — a spotlight dims the whole panel except one element. (Under the hood it's a fullscreen overlay, a sibling to Circle Hole, whose SDF filter shader punches a soft, rounded-rect hole at the target — the same SDF the effects use — and animates that hole between targets.)
Spotlight has three ways to use it — pick by how much you need.
A multi-step tour — the fluent SpotlightTour builder:
using CupkekGames.Luna.Spotlight;
// `root` is your panel's visual tree (e.g. from a PanelRenderer reload callback).
var tour = new SpotlightTour()
.Step("play-button", "Tap here to start your first run.")
.Step("inventory-panel", "Your loot lives here.").Title("Inventory")
.Step("settings-gear", "Audio and controls live in Settings.")
.OnComplete(() => PlayerPrefs.SetInt("onboarded", 1))
.OnSkip(() => PlayerPrefs.SetInt("onboarded", 1))
.CreatePlayer(root)
.Begin();Title / Caption are text you render — Spotlight draws none itself; see
Captions and content for how.
A single highlight, in code — the static LunaSpotlight facade:
var handle = LunaSpotlight.Focus(root.Q("daily-reward"));
// ...later
LunaSpotlight.MoveTo(root.Q("shop-button")); // animates the hole across
LunaSpotlight.Dismiss(); // fades outNo element at the target? The hole can also anchor to a panel-space rect or a camera-world point — for cut-outs over board objects or screen regions that have no UI element under them:
// Fixed screen region (panel units)
LunaSpotlight.Focus(root.panel, new Rect(220, 2152, 1000, 370), options);
LunaSpotlight.MoveTo(new Rect(120, 400, 600, 300)); // animates, same as element MoveTo
// Camera-world point: the hole keeps following the point while the camera moves
LunaSpotlight.Focus(root.panel, Camera.main, boardPiece.position, new Vector2(800, 350), options);Everything composes as with an element target: Padding, Shape, move/fade animation, and the
input modes. Under ClickTarget, clicking inside the hole advances (there is no element to click,
so the hole itself is the click target). A negative CornerRadius has no element style to follow
here and degrades to padding-only corners; set it explicitly for rounded region holes. Screen-space
panels only — the world overload projects through
UITKCoordinateUtility.WorldToPanel(panel, camera, worldPoint), which is public if you only need
the math.
A single highlight, no code — add a SpotlightFocus
component to the UI GameObject, point its Target selector at an element, and enable
Focus On Load (or call Focus() / Dismiss() from script).
Spotlight works out of the box — with no setup it uses a Painter2D fallback (hard edges, no
feather). For soft, feathered edges, wire the filter once: assign a Spotlight Filter
Settings asset to LunaUIManager → Spotlight Settings
(Essentials/Effects/SpotlightFilterSettings).
Every hole is a hard-edged rectangle? You are on the fallback:
CornerRadiusandSoftnessonly apply on the shader renderer, so without the settings asset they are ignored no matter what the options say.SpotlightFilterSettings.IsReadyis the first thing to check; a one-time console warning also names this at play start.
Tour only — single highlights use the SpotlightFocus
Input field instead.
Each step has an advance mode (how the player moves on) plus an optional auto-advance timer that fires on top of it.
Advance mode (SpotlightAdvance):
| Mode | Behavior |
|---|---|
ClickAnywhere (default) | The dim layer blocks the UI; a click anywhere advances. |
Manual | Input blocked; advance only when the game calls player.Next() — wire your own "Next" button. |
ClickTarget | Only the highlighted element is clickable (input passes through the hole); clicking it advances — "learn by doing". |
Spotlight ships no Next / Skip buttons — they're yours. Wire your own to player.Next() /
player.Skip() (and Previous()). Because the dim blocks the UI underneath, they must render
above it — put them in the content layer.
Auto-advance is opt-in: set a step's auto-advance seconds > 0 and it also advances after
that delay (the player can still advance early). Leave it 0 and the step waits for input. Set
it per step with .AutoAdvanceAfter(seconds) (and the advance mode with .AdvanceOn(mode));
set tour defaults with .DefaultAdvance(mode) / .DefaultAutoAdvance(seconds). (On the
SpotlightTourController, each step has an Auto Advance Seconds field.)
The dim overlay is the panel's topmost child, so anything a view renders — a caption, an arrow, a hand pointer — sits below it and gets dimmed along with the rest of the screen. Spotlight therefore owns a content layer: a container inside the overlay, above the dim, that fades with it and is cleared on dismiss.
Tours render a caption by default. A step's Title / Caption (.Step(target, "caption")
/ .Title(...), or the inspector fields) appear in a Luna-styled box in the content layer,
auto-positioned against the hole — below it, flipping above when out of space — and following
it through move animations and world tracking. Restyle it via the .luna-spotlight-caption
classes (see Styling below). Rendering your own instead is one switch:
.RenderDefaultCaption(false) on the tour (or the Render Default Caption toggle on
SpotlightTourController).
Custom chrome goes in handle.Content. The container is PickingMode.Ignore; children
opt into picking themselves, so Next / Skip buttons work while ClickTarget hole clicks still
pass through. Content is clipped at the panel edge — beyond-panel chrome was never visible,
and unclipped it would inflate the shader's capture bounds and distort the hole. Style
Content children from a panel-level sheet (the theme or a sheet on the panel root): an
element you build in a view and add to Content leaves that view's UXML subtree, so the view's
own <Style src> no longer reaches it.
handle.HoleRect is the displayed hole in panel coordinates and handle.OnHoleChanged fires
whenever it moves — position against them:
var handle = LunaSpotlight.Focus(target, options);
var bubble = BuildMyBubble(); // your own element, pickable if it has buttons
handle.Content.Add(bubble);
Position(bubble, handle.HoleRect);
handle.OnHoleChanged += hole => Position(bubble, hole); // follows moves + world tracking
// no cleanup needed: Content is cleared when the spotlight dismissesFor tours, do the same from the step-changed callback (SpotlightTour.OnStepChanged /
SpotlightTourController.StepChanged) with RenderDefaultCaption(false) — also settable at
runtime via the controller's RenderDefaultCaption property before Play(). The player
mirrors the surface: player.Content, player.HoleRect, player.OnHoleChanged. The bundled
SpotlightTour demo (Showcase → Components → Spotlight) builds its Skip/Next card exactly
this way.
Older guidance said to parent tutorial UI to
panel.visualTreeandBringToFront()it. Don't: that fights the navigation system's automatic z-order and breaks on the next nav operation. The content layer exists so nothing outside the overlay needs reordering.
Sometimes the classic uGUI pattern fits better than a cut-out: dim everything and lift the interesting things above the dim. A hole un-dims a region, so elements overlapping the target show through it too; elevation shows only what you lift.
var handle = LunaSpotlight.Dim(root.panel, options); // full dim, no hole (Shape = None)
handle.Elevate(root.Q("claim-button")); // lifted above the dim, still clickable
handle.Content.Add(myArrow); // chrome composes as usual
// ...on dismiss the elevated element returns to its original parent, index and inline layoutElevate moves the live element (a copy would be non-interactive and blind to live data,
and UITK has no per-element sort order to render it above in place), pins it at its current
position inside the content layer, and restores it — parent, slot, inline layout — on dismiss.
An in-flow element leaves a same-size, margin-matched placeholder in its slot, so its
siblings do not reflow behind the translucent dim while it is lifted — and the pin follows
that slot every frame, dismiss fade included, so a container that animates (an exit slide, a
layout shift) carries the lifted element with it and restore lands with no jump. Absolute
targets ride their original parent's movement instead. One documented bound: the tracking
samples the previous frame's resolved transform (UITK advances transitions after Luna's tick
in the same update), so a fast-moving container leads the lifted element by at most one
frame of its own motion — about 1% of screen height at a half-second full-height slide,
found in instrumented traces rather than on screen. It stays interactive —
with BlockAll, the elevated element becomes the only clickable thing on screen, which is
the "learn by doing" setup without any hole. Use ClickAnywhere or Manual advance with
Shape.None; there is no hole for ClickTarget to forward to.
Ancestor-scoped USS keeps matching. Reparenting would normally break selectors that depend
on an ancestor (.pbar--hard .pbtn, breakpoint root classes) — variant-on-the-container is
mainstream theming, so Elevate mirrors the element's real ancestor chain inside the content
layer: one neutralized wrapper per ancestor, carrying its name, classes and attached style
sheets (a view's <Style src> lands on the view root, so without the sheets the lifted
subtree would lose every view-authored rule, not just variant ones), matching descendant and
> chains alike while painting nothing itself (its own box and visuals are zeroed inline).
Pass Elevate(element, mirrorAncestorContext: false) to skip it.
What the mirror cannot reproduce, worth knowing: ancestor type selectors (ScrollView .item — the wrapper is a plain VisualElement), structural and state pseudo-classes on
ancestors (:nth-child, :hover), and inherited values that came from non-class sources.
Screen-space panels only.
The dim overlay uses .luna-spotlight-overlay, the content layer .luna-spotlight-content,
and the default caption box .luna-spotlight-caption with __title / __text children (all
in LibSpotlight.uss, imported by the Luna theme). Override them in your theme to restyle;
anything you attach to Content yourself is your own UI, styled however you like.
To author a tour in the inspector instead of code, add a SpotlightTourController to the
UI GameObject (the one with the PanelRenderer, or any child of it — it auto-finds the
renderer):
#name / .class / Type), plus the caption / title text
and advance mode. Tour-level defaults (dim, padding, shape, default advance) live on the
same component.controller.Play() (or Stop()) whenever you want. With
Play On Load it starts itself once the UI is ready (after a small Start Delay so
layout settles). To replay from a button, call Play() from that button's click — e.g.
root.Q<Button>("help").clicked += controller.Play;. Wire your Next / Skip buttons to
controller.Player.Next() / .Skip(), and listen to controller.StepChanged /
Completed / Skipped to drive them.A tour is screen-specific, so the steps live directly on the controller — there's no separate
asset. It's a self-contained controller (in the spirit of UIAttractor): it resolves the
panel root, builds the tour from its inline steps, and plays it.
SpotlightFocus dims one element as a drop-on component — no tour, no caption. Add it to the
UI GameObject (it auto-finds the PanelRenderer) and set its Target selector. That target
is just the default — used by Focus() and Focus On Load.
Move the highlight at runtime with the same targeting vocabulary as the inspector — the
component resolves it under its own UI root, so you never grab the root yourself. Every
Focus(...) animates the hole across if a spotlight is already showing:
spotlightFocus.Focus(); // the inspector default target
spotlightFocus.Focus("inventory-panel"); // by element name
spotlightFocus.Focus(new ElementSelector { Selector = "#shop > .badge" }); // by selector
spotlightFocus.Focus(someVisualElement); // by explicit element
spotlightFocus.Dismiss(); // fade outIts Input field chooses how the dim treats pointer input:
| Input | Behavior |
|---|---|
PassiveNoBlock (default) | Purely visual — the UI stays interactive. |
ClickAnywhere | Block the UI; a click raises the handle's OnAdvance. |
ClickTarget | Block everything except the highlighted element. |
BlockAll | Block all input. |
The visual fields (dim color, padding, shape, corner radius, softness, durations, easing)
mirror SpotlightOptions.
Each step (or SpotlightFocus) targets its element with a Luna ElementSelector — the same
CSS-like picker used elsewhere in Luna:
#play-button — by name.hud-play — by USS classButton — by type (inheritance-aware: a selector for a base type also matches its subclasses)A B (descendant), A > B (child), A, B (union)The first match (DOM order) is highlighted, resolved under the UI root at play time. In code you can target three ways:
new SpotlightTour()
.Step(playButtonElement, "...") // an explicit VisualElement
.Step("play-button", "...") // by name (root.Q)
.Step(new ElementSelector { Selector = "#play-button" }, "..."); // selectorUse the code builder when you need dynamic targets or per-step side effects (.OnEnter(...));
use the SpotlightTourController component for static, designer-authored tours.
SpotlightOptions (per call, or .DefaultOptions(...) / .WithOptions(...) per step):
| Field | Default | Description |
|---|---|---|
DimColor | rgba(0,0,0,0.72) | Overlay fill outside the hole. |
Padding | 8 | Breathing room (px) around the target. |
Shape | RoundedRect | RoundedRect / Circle / Pill / Rect / None (full dim, no cut-out — see Dim without a hole). |
CornerRadius | -1 | RoundedRect corner px; negative = follow the target's own border-radius. |
Softness | 6 | Soft feather (px) at the hole edge (shader renderer only). |
MoveDuration | 0.35 | Seconds to animate the hole between targets. |
FadeDuration | 0.25 | Seconds to fade the overlay in/out. |
Easing | EaseOutCubic | Easing for move + fade. |
SpotlightTour) — OnStepChanged(index, step), OnComplete(), OnSkip().SpotlightTourController) — StepChanged(index, step), Completed, Skipped: the player's callbacks forwarded as C# events (wire once; they survive Play() restarts).SpotlightTourPlayer) — Begin(), Next(), Previous(), Skip(), Stop().SpotlightHandle, from LunaSpotlight.Focus) — OnAdvance, OnDismissed.Settings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction