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.)

Quick start

Spotlight has three ways to use it — pick by how much you need.

A multi-step tour — the fluent SpotlightTour builder:

csharp
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:

csharp
var handle = LunaSpotlight.Focus(root.Q("daily-reward")); // ...later LunaSpotlight.MoveTo(root.Q("shop-button")); // animates the hole across LunaSpotlight.Dismiss(); // fades out

No 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:

csharp
// 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).

Setup

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: CornerRadius and Softness only apply on the shader renderer, so without the settings asset they are ignored no matter what the options say. SpotlightFilterSettings.IsReady is the first thing to check; a one-time console warning also names this at play start.

Advancing steps

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):

ModeBehavior
ClickAnywhere (default)The dim layer blocks the UI; a click anywhere advances.
ManualInput blocked; advance only when the game calls player.Next() — wire your own "Next" button.
ClickTargetOnly 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.)

Captions and content above the dim

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:

csharp
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 dismisses

For 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.visualTree and BringToFront() 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.

Dim without a hole (elevation)

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.

csharp
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 layout

Elevate 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.

Styling

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.

No-code authoring (the controller)

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):

  1. Author the steps inline in the inspector. Each step picks its target with a Luna ElementSelector (#name / .class / Type), plus the caption / title text and advance mode. Tour-level defaults (dim, padding, shape, default advance) live on the same component.
  2. Trigger from script — call 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.

Single highlight (the component)

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:

csharp
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 out

Its Input field chooses how the dim treats pointer input:

InputBehavior
PassiveNoBlock (default)Purely visual — the UI stays interactive.
ClickAnywhereBlock the UI; a click raises the handle's OnAdvance.
ClickTargetBlock everything except the highlighted element.
BlockAllBlock all input.

The visual fields (dim color, padding, shape, corner radius, softness, durations, easing) mirror SpotlightOptions.

Targeting

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 class
  • Button — by type (inheritance-aware: a selector for a base type also matches its subclasses)
  • combinators: 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:

csharp
new SpotlightTour() .Step(playButtonElement, "...") // an explicit VisualElement .Step("play-button", "...") // by name (root.Q) .Step(new ElementSelector { Selector = "#play-button" }, "..."); // selector

Use the code builder when you need dynamic targets or per-step side effects (.OnEnter(...)); use the SpotlightTourController component for static, designer-authored tours.

Options

SpotlightOptions (per call, or .DefaultOptions(...) / .WithOptions(...) per step):

FieldDefaultDescription
DimColorrgba(0,0,0,0.72)Overlay fill outside the hole.
Padding8Breathing room (px) around the target.
ShapeRoundedRectRoundedRect / Circle / Pill / Rect / None (full dim, no cut-out — see Dim without a hole).
CornerRadius-1RoundedRect corner px; negative = follow the target's own border-radius.
Softness6Soft feather (px) at the hole edge (shader renderer only).
MoveDuration0.35Seconds to animate the hole between targets.
FadeDuration0.25Seconds to fade the overlay in/out.
EasingEaseOutCubicEasing for move + fade.

Events

  • Tour builder (SpotlightTour) — OnStepChanged(index, step), OnComplete(), OnSkip().
  • Controller (SpotlightTourController) — StepChanged(index, step), Completed, Skipped: the player's callbacks forwarded as C# events (wire once; they survive Play() restarts).
  • Player verbs (SpotlightTourPlayer) — Begin(), Next(), Previous(), Skip(), Stop().
  • Single highlight (SpotlightHandle, from LunaSpotlight.Focus) — OnAdvance, OnDismissed.

See also

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL