Author your screen flow as a graph — and get a real back button (Esc / hardware back / controller B), tabs that remember their stacks, and awaitable modals for free. Navigate by string id from anywhere:
LunaNavigation.Push("shop.home"); // show a screen
var result = await LunaNavigation.PushAsync<bool>("confirm.delete"); // modal that returns a value
LunaNavigation.PopBackStack(); // back, from code
This is Luna's screen push / pop stack. Use it for any UI flow with back-button semantics — modals, drill-downs, tab switches, full-screen takeovers.
Not Focus Navigation. That page is about arrow-key focus within a panel. This page is about pushing whole screens.
Navigation is split into a small set of orthogonal assets/components. Author them once, then navigate by string id forever after.
| Piece | What it is | Type |
|---|---|---|
| Destination | One screen / modal / tab — its id + policy | NavNode (a node inside a graph) |
| Graph | The set of destinations + their containment | NavGraphSO asset |
| Catalog | Bakes id → NavNode for runtime id resolution | NavDestinationCatalog asset (optional — see below) |
| Host | Scene component that mounts a graph as a live UI layer | NavHost MonoBehaviour |
| Config | The PanelSettings (scale / theme / sort-order) a host applies | NavConfigSO asset |
The contract that ties it all together is the id. NavNode.Id ⇒ the catalog key ⇒ LunaNavigation.Push("that.id"). Keep ids flat, globally unique, and dotted by convention (shop.home, hero.detail, settings).
NavGraphSO (asset) NavHost (scene) ──mounts──► graph
├─ NavNode "shop.home" ◄──┐ └─ NavConfigSO (PanelSettings)
├─ NavNode "shop.item" ───┘ containment edge (parent → child)
└─ NavNode "shop.tabs" (ChildMode = Switched) ⇒ its children are tabs
LunaNavigation.Push("shop.home") ──resolves off──► the merged topology of all mounted hostsWhy the split:
NavTopology at load — never stored on the node.settings) is reachable from every scene.Dependencies (v2.3.2): the graph canvas comes from
com.cupkekgames.graphsand theCatalogKeymachinery fromcom.cupkekgames.data. Both are declared dependencies ofcom.cupkekgames.luna, so they arrive automatically when you install Luna — nothing extra to add.
NavNode per destination, set each node's Id and Prefab, and wire containment edges.PanelSettings this layer renders with.NavHost to a GameObject, assign the graph + config. Make sure a LunaUIManager is in the scene.using CupkekGames.Data;
using CupkekGames.Luna.Navigation;
[CatalogKeyConstraint(NavConstants.NavDestinationCatalogId)]
[SerializeField] private CatalogKey _heroDetail; // validated id dropdown
void OnRosterRowClicked(Hero hero)
{
LunaNavigation.Push(_heroDetail, new HeroDetailArgs { Hero = hero });
// or, by raw id: LunaNavigation.Push("hero.detail", new HeroDetailArgs { Hero = hero });
}Pop with Esc / back-button / backdrop click, or programmatically with LunaNavigation.PopBackStack().
Create with Project → Create → Luna → Nav → Nav Graph (one graph per feature/scene, e.g. RootNav, ShopNav). Double-click the asset to open the canvas (also reachable via Window → CupkekGames → Graphs → Graph Editor).
NavNode per destination. Select a node to edit its fields in the docked inspector. The node's Id is the Push id / catalog key — the field autocompletes existing ids across every graph as you type, and double-clicking the node header renames it (which writes the id).NavTopology.Build at load.The canvas badges policy at a glance: Tabs on a Switched container, seed for Start Visible, replace for Replace occlusion, backdrop, multi for multi-instance — plus a derived tab badge and a per-channel accent tint once the topology resolves.
NavNode field referencePush("<id>") resolves to this destination. Flat, globally unique, dotted by convention. Required to be reachable by id.UIViewComponent (see prefab rules).true ⇒ born opacity:1 / display:flex and the host auto-pushes it at boot (sibling-order). false (default) ⇒ born hidden, off-stack until pushed.false (default): re-push reuses the existing frame. true: each push spawns a fresh copy (notifications, side-by-side dialogs).OnStateReset(args) before each reopen (2nd+ push). For confirm dialogs / compose forms that should look fresh every open.false for host-spawned views (the host-preloaded instance is always preserved regardless).true). Keeps keyboard/gamepad focus from wandering behind a modal.VisualTreeAsset cloned as backdrop content when Backdrop = Custom.Reference destinations from inspectors with the house CatalogKey pattern:
[CatalogKeyConstraint(NavConstants.NavDestinationCatalogId)]
[SerializeField] private CatalogKey _settingsDest;In the editor this works with zero setup. NavDestinationAutoCatalog (editor-only, auto-registered) scans every NavGraphSO in the project and surfaces every NavNode.Id to those dropdowns — no catalog asset, no ServiceRegistrySO wiring. It is never live during play: runtime navigation resolves ids off the mounted hosts' merged NavTopology, not a catalog.
A NavDestinationCatalog asset is only needed when runtime code resolves a node object by id (e.g. via NavDestinationResolver to read a node's policy). Create it with Project → Create → Luna → Nav → Destination Catalog, add your graphs to its Graphs list, leave the catalog id at its default (NavConstants.NavDestinationCatalogId, "NavDestination"), and register it through a ServiceRegistrySO like any other catalog. Then:
NavNode node = NavDestinationResolver.Resolve("shop.home"); // or Resolve(catalogKey)An unset CatalogKey has an empty Key; the nav verbs no-op on an unknown id (with a logged warning), so guard optional slots with !key.IsEmpty if you want them silently skipped.
PanelSettings.NavHost component to a scene GameObject and assign:
NavGraphSO to mount.NavConfigSO from step 1.LunaLayers.AllReady. Turn off for hosts that load lazily (e.g. a HUD spawned after boot) so they don't hold the loader open; they still participate in PlayAll / HideAll.DontDestroyOnLoads the host (and its views). Use for a global UI graph.On Awake (default) spawns views in Awake. Manual still registers the host at Awake (so it counts toward LunaLayers.AllReady) but defers spawning until a sequencer node calls Boot() — used by the sequencer-driven loading screen so hosts spawn only after their services exist.LunaUIManager is in the scene — drag Assets/Samples/LunaUI/<version>/Essentials/Prefabs/LunaUIManager.prefab in (it comes pre-wired with the effect settings, icon database, and audio), or add the LunaUIManager component to a GameObject manually — its required UIInteractableAudioHandler is added automatically via [RequireComponent]. It builds the nav stack in Awake at execution order −3000, before any host (default order 0). If you use a boot loading screen, LoadingScreen sits between them at −2000.PlayerInput (or PlayerInputManager) on LunaUIManager for Esc / controller-B — the Essentials prefab ships that field empty (the Essentials Luna_InputSystem_Actions asset works as its Actions; the manager logs a one-time warning at startup if it's missing).⚠️ No EventSystem, no clicks. UI Toolkit pointer dispatch needs an EventSystem in the scene — without one, clicks (including backdrop-click dismissal) never reach the views. Esc / controller-B go through the Input System instead, so they additionally need a
PlayerInput(orPlayerInputManager) assigned onLunaUIManager— the Essentials prefab ships that field empty.
On play, the host: registers with LunaLayers → builds + registers its NavTopology into the merged index → spawns each node's prefab (parents before children, siblings in edge order, children parented under their spawned parent's GameObject) → applies the config's PanelSettings to every spawned view's PanelRenderer → load-gates on every view's panel → seeds the Start Visible nodes onto their stacks → reports ready.
NavHost API surface:
public bool IsReady { get; } // every view loaded + seed done
public event Action OnReady; // fires once
public IReadOnlyList<UIViewComponent> Views { get; } // spawn order
public UIViewComponent GetView(string id); // spawned view for a node id
public void WhenReady(Action action); // now-or-later convenience
public bool IsBooted { get; } // views spawned (Boot ran)
public void Boot(); // spawn views now — Manual boot mode
public void PlayAll(); // push every owned node
public void HideAll(); // close every owned nodeLunaLayers is the static facade over every live host: All, AllReady, OnAllReady (rising-edge), WhenAllReady(Action), BootDeferred() (boots every Manual host), PlayAll(), HideAll().
Put always-available destinations (Settings, confirm modals, toasts) in their own NavGraph as forest roots, mounted on a NavHost with Persist Across Scenes = on (or placed in a boot scene). Each host registers its topology into the shared union, so Push("settings") resolves from any scene. There is no "global destination" type — it's a normal graph on a persistent host.
Every mounted host's topology is unioned into one id index; hosts unregister on destroy (scene unload). Ids must be unique across mounted graphs: a duplicate id logs an error and the first registration wins. Within one graph the canvas footer flags duplicates; across graphs the Problems panel of the Graph Runtime Debugger catches them at author time.
Every destination prefab the host spawns must:
UIViewComponent (or subclass) on its root. The host wires the node at spawn via UIViewComponent.SetNode — there is no destination field to assign on the prefab.PanelSettings of its own. The host applies the NavConfigSO's settings to each spawned view's PanelRenderer. A stray PanelSettings is the usual cause of a view rendering at the wrong scale/sort.All verbs live on the static LunaNavigation facade and take the destination's stable string id. Exact signatures:
public static class LunaNavigation
{
public static LunaNavigationStack Instance { get; } // LunaUIManager.Instance.Navigation
public static void Push(string id, object args = null);
public static void Push<TArgs>(string id, TArgs args) where TArgs : class;
public static void ReplaceTop(string id, object args = null);
public static void PopUpTo(string id, bool inclusive = false);
public static void ClearStackAndPush(string id, object args = null);
public static void PopBackStack();
public static void Close(string id);
public static void SwitchChannel(string channelId);
public static Task<PushResult<T>> PushAsync<T>(string id, object args = null);
// CatalogKey overloads — each forwards its Key to the string verb above.
public static void Push(CatalogKey key, object args = null);
public static void Push<TArgs>(CatalogKey key, TArgs args) where TArgs : class;
public static void ReplaceTop(CatalogKey key, object args = null);
public static void PopUpTo(CatalogKey key, bool inclusive = false);
public static void ClearStackAndPush(CatalogKey key, object args = null);
public static void Close(CatalogKey key);
public static void SwitchChannel(CatalogKey key);
public static Task<PushResult<T>> PushAsync<T>(CatalogKey key, object args = null);
public static void Pop<T>(T result); // resolves the top frame's awaiter
public static void AddGuard(NavGuard guard);
public static void RemoveGuard(NavGuard guard);
// Boot hold (initial-load preloader pattern — see Loading-screen section)
public static bool IsBootHeld { get; }
public static void HoldBoot();
public static void ReleaseBoot();
}| Verb | Effect |
|---|---|
Push(id, args) | Push the destination onto its stack (auto-switches channel if it's tab-bound). Unknown id → warning + no-op. |
Push<TArgs>(id, args) | Typed push — TArgs inferred from the call site (Push("hero.detail", new HeroDetailArgs(hero))). |
ReplaceTop(id, args) | Pop the top of the visible stack, then push. |
PopBackStack() | Pop the top of the active visible stack (bubble-up rules). |
PopUpTo(id, inclusive) | Pop the visible stack down to a destination. |
ClearStackAndPush(id, args) | Drain the visible stack, then push. |
Close(id) | Force-close a specific destination wherever it is (cascade-closes its descendants). |
SwitchChannel(channelId) | Switch the active tab. The id must be a tab (a Switched container's child). |
await PushAsync<T>(id, args) | Push a modal and await its result; Pop<T>(value) resolves it. |
State reads and events live on the instance (LunaNavigation.Instance):
public NavNode CurrentNode { get; } // topmost visible destination
public string CurrentChannelId { get; } // deepest active channel id (null = root only)
public NavNode CurrentChannel { get; }
public IReadOnlyList<string> ActiveChannelPath { get; } // outermost → innermost
public int StackDepth { get; } // root + active path ("show back button when > 1")
public object CurrentArgs { get; }
public T GetArgs<T>() where T : class;
public event Action<NavNode> DestinationChanged;
public event Action<NavNode> ChannelChanged;
public event Action<NavEvent> OnPushed; // payload: Node, Args, ChannelId
public event Action<NavEvent> OnPopped; // fires on every removal (pop, cascade, scene unload)SwitchChannel fires first.OnStateReset fires if authored.Define your own args class; no dictionary, no string keys.
public class HeroDetailArgs
{
public Hero Hero;
public string Origin;
}
LunaNavigation.Push("hero.detail", new HeroDetailArgs { Hero = h, Origin = "leaderboard" });The view reads its args off its own UIViewComponent. Remember the panel arrives asynchronously — do element queries in the OnUILoaded override (or via WhenUILoaded), and per-push refresh in a fade hook:
public class HeroDetailScreen : UIViewComponent
{
protected override void OnUILoaded(VisualElement root)
{
// Element queries are safe here: root is this view's subtree.
UIView.Fade.OnFadeInStart += OnFadeInStart;
}
void OnFadeInStart()
{
var args = GetArgs<HeroDetailArgs>();
if (args != null) BindHero(args.Hero);
}
}GetArgs<T>() returns null when args weren't set or aren't of type T. The same handler covers both.
var result = await LunaNavigation.PushAsync<int>("shop.quantityPicker");
if (result.IsDismissed) return;
ApplyQuantity(result.Value);The modal's confirm button calls LunaNavigation.Pop<int>(amount); any other close path (Esc / cascade / scene unload / re-push) resolves the awaiter with IsDismissed = true. A type mismatch between PushAsync<T> and Pop<T> logs a warning and resolves as dismissed.
Pre-push interceptors. Guards are synchronous predicates by design — the delegate is NavGuardResult NavGuard(NavGuardContext context). They fire before every push in registration order; the first Block wins; a guard that throws is logged and treated as Allow.
LunaNavigation.AddGuard(ctx =>
{
if (ctx.Node.Id == "account.delete" && !FeatureFlags.AllowDestructive)
return NavGuardResult.Block;
return NavGuardResult.Allow;
});For an async confirm flow (e.g. an "unsaved changes" modal), don't gate it in a guard — await at the call site and push conditionally:
if (await ConfirmDiscard()) LunaNavigation.Push("editor.next");A channel is a routing scope with its own back stack. You don't author channels directly — they're derived from graph shape: set a container node's Child Mode = Switched and each of its children becomes a tab that owns its own parallel stack. A destination's owning channel is its nearest tab ancestor (including itself).
shop.tabs (ChildMode = Switched) ← tab container
├── shop.home ← a tab (owns a stack)
│ └── shop.item (lives on shop.home's stack)
└── shop.cart ← a tab (owns a stack)
└── shop.checkout (lives on shop.cart's stack)Switching:
LunaNavigation.SwitchChannel("shop.home");
LunaNavigation.SwitchChannel("shop.cart"); // cross-fade; shop.home's stack is preservedSwitchChannel walks the target's containment chain to build the new active path. Channels leaving the path hide their tops; channels entering show their tops (auto-pushing the tab itself as the base frame when their stack is empty). Dormant channels keep their stacks — switching back restores the drill-down exactly where the user left it.
The luna:TabView UXML element automates this: enter the tab node ids into its Destinations attribute (List<string>, in tab-button DOM order) and it calls SwitchChannel for you. See its Channels mode.
PopBackStack (and Esc) pops the deepest non-empty stack:
Set Is Multi Instance on the node. Each Push reuses a free live instance or spawns a fresh copy from the node's prefab; Pop destroys the copies (the host-preloaded original is always kept).
LunaNavigation.Push("toast", new ToastArgs { Text = "Saved", Tone = ToastTone.Success });
LunaNavigation.Push("toast", new ToastArgs { Text = "Auto-synced", Tone = ToastTone.Info });
// Two independent toasts visible at once.This is also the dynamic screens pattern: one archetype node ("toast", "dialog.generic") + per-push args that drive the content. There's no per-push node creation — the node is the archetype, the args are the instance data.
UIPrefabLoader stays for one-off VFX (damage numbers, screen-shake) that aren't stack frames — use it for fire-and-forget effects, the nav stack for anything with back-button semantics.
All dismiss intents — Esc, hardware back, controller B, backdrop click — are governed uniformly by the node's Dismiss Mode:
| Mode | Esc / back / B | Backdrop click |
|---|---|---|
| Pop (default) | Pops this destination | Pops this destination |
| Block | Consumed, does nothing; does not fall through | Suppressed |
| PassThrough | No handler registered — input flows to whatever is below (parent frame, game-level shortcut) | Suppressed |
Programmatic PopBackStack() / Close(id) always work regardless of mode.
Backdrops are inserted as the first child of the view's root when the node authors one: Dim is a semi-transparent black layer; Custom clones the node's VisualTreeAsset. The element carries the USS class luna-nav-backdrop for styling overrides. Authoring contract: backdrop-using destinations need a panel-filling view root so the backdrop covers the screen — wrap centered-window modals in a panel-filling outer VisualElement.
Host-spawned view GameObjects live for the host's lifetime. Push shows / Pop hides the visual state — the MonoBehaviour stays alive across cycles, so TextField text, ScrollView positions, and Animator states all persist between push and pop. Dormant tab channels likewise preserve their whole stack.
If a view should reset on every visit, enable Reset State On Reopen on the node and override OnStateReset(object args) in the view to clear + re-seed. It fires before each reopen (the 2nd and later pushes); the first push doesn't fire it — the view's state is fresh by construction.
Per-push lifecycle hooks come from FadeUIElement events on UIView.Fade:
| Event | Fires when | Use for |
|---|---|---|
OnFadeInStart | Fade-in begins | Per-push refresh — read args, update labels, repopulate lists. |
OnFadeIn | Fade-in completes | Focus an element, fire analytics. |
OnFadeOutStart | Fade-out begins | Unhook external subscriptions. |
OnFadeOut | Fade-out completes | Final cleanup. |
The boot loader is not a nav destination. LoadingScreen is authored as a scene-root GameObject with its own PanelRenderer + high-sort-order PanelSettings — putting it in a graph would entangle it with channel routing and push/pop lifecycles that don't belong to a boot overlay (it would also be load-gated by the very host it must wait on). It has two reveal paths, and only the first uses the boot hold.
Auto-reveal (single-scene boot). Enable the loader's Auto-reveal (boot loader, no sequencer) option (_autoRevealOnAllReady). The flow:
LoadingScreen.Awake (execution order −2000, after LunaUIManager at −3000, before every NavHost at 0) calls LunaNavigation.HoldBoot() — from then on every push (host Start-Visible seeds, TabView auto-select, consumer code) queues instead of executing.NavHost spawns its views and load-gates; the loader sits on top by sorting order.LunaLayers.WhenAllReady — when every gating host reports ready (and the configurable minimum visible time has elapsed), it fades itself out.LunaNavigation.ReleaseBoot() — the queued pushes drain in arrival order and run their full lifecycle (fade-in animations, OnFadeInStart handlers) after the loader is gone.Sequencer-driven (scene loads / multi-scene boot). The loader is shown by a scene transition and hidden by a sequencer node — it does not hold boot. Author the hosts as NavHostBoot.Manual (they register at Awake so they count toward readiness, but defer spawning), then run BootNavGraphsNodeSO (→ LunaLayers.BootDeferred()) to boot them once services exist, followed by RevealLoadingScreenNodeSO to fade the loader out once LunaLayers.AllReady.
See Loading for both paths end to end.
Hosts that shouldn't hold the loader open (a lazily-spawned HUD) set Gates All Ready = off.
PushAsync awaiters created during the hold resolve as dismissed — don't await boot pushes.
com.cupkekgames.graphs). Every mounted NavHost registers its graph there; the window's Problems panel also surfaces nav's author-time wiring checks: duplicate ids across a catalog's graphs, NavGraphSOs in no catalog, and catalogs in no ServiceRegistrySO.NavTopology.Build at the host's Awake; missing ids are flagged only in the graph footer (warning) — at load such nodes are silently unaddressable.object (a wrong-type read via GetArgs<T>() returns null).PopUpTo / ReplaceTop / ClearStackAndPush operate on the visible stack only (root if non-empty, else the active channel). Cross-channel batch pops are out of scope — sequence per-channel verbs explicitly.NavHost owns that node (its graph isn't mounted in this scene, or the host hasn't awakened), or the node's Prefab is null. Global destinations belong on a Persist Across Scenes host.PanelSettings. Remove it; the host's NavConfigSO is the single owner.SwitchChannel warns "not a tab" — the target id's parent isn't a Switched container. Tab-ness is derived from the parent's Child Mode, never set on the tab itself.OnUILoaded, WhenUILoaded, GetArgs<T>)Runtime/Scripts/Navigation/ in the Luna packageReading along without the package? The nav system ships with everything else in Luna — get it on the Asset Store.
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