Navigation Host

Luna's first-class navigation primitive. Use LunaNavigationHost when your shell needs a push/pop stack of screens — detail overlays, modal popups, drill-down inspectors — with typed contexts, automatic Esc-to-back, and parent visibility management. Pair with LunaTabHost for top-level tab routing.

Two navigation models, pick the right one. Luna ships both because they fit different shell shapes. Prefab-spawned modals (the older model — UIPrefabLoaderString.Instantiate(key) returns a GameObject with its own UIDocument + UIViewComponent, Esc closes via _addDefaultEscapeAction) are great when each modal is a separate, fully-isolated view. LunaNavigationHost (this page) is for single-shell scenes where the panel is one big UIDocument and every navigable destination is a VisualElement subtree cloned into a container slot. The Showcase sample uses the latter; many game UIs mix both.

Quick start

csharp
using CupkekGames.Luna.Navigation; // In your shell controller's Awake: public class GameShell : UIViewComponent { [SerializeField] VisualTreeAsset _heroDetailVta; // VTA destination [SerializeField] GameObject _confirmModalGo; // in-scene prefab destination LunaNavigationHost _nav; protected override void Awake() { base.Awake(); var detailOverlay = ParentElement.Q<VisualElement>("DetailOverlay"); _nav = new LunaNavigationHost(detailOverlay); // VTA-cloned destination — a plain INavigationScreen class created // fresh per push. _nav.Register(new VtaDestination<HeroDetailContext>( _heroDetailVta, () => new HeroDetailScreen())); // In-scene prefab destination — the modal MonoBehaviour implements // INavigationScreen<Config> directly. Same component instance reused // across pushes (host's single-instance guard handles "don't push // twice"). _nav.Register(new InSceneDestination<ConfirmModalView.Config>( _confirmModalGo, () => _confirmModalGo.GetComponent<ConfirmModalView>())); } public void OpenHeroDetail(HeroCard card) => _nav.Push(new HeroDetailContext(card)); public void Confirm(string title, string message, Action onConfirm) => _nav.Push(new ConfirmModalView.Config { Title = title, Message = message, OnConfirm = onConfirm }); }

The context — new HeroDetailContext(card) or new ConfirmModalView.Config { … } — is both the type-key for dispatch and the per-push payload. LunaNavigationHost looks up the registered destination for that type, runs its render strategy (clone VTA into the overlay, OR activate the in-scene GameObject), creates the screen instance via the factory, and calls screen.OnEnter(view, context, host) so the screen wires UXML to context before any animation runs.

See Two render strategies, two ways to be a screen below for the full pattern comparison.

Core types

TypeRole
LunaNavigationHostThe runtime stack manager. One per shell scene; takes a container VisualElement.
INavigationDestination<TContext>Bundles a render strategy + a screen factory for one route.
INavigationScreen<TContext>The screen's lifecycle contract — OnEnter(view, context, host) + OnExit().
INavigationRenderStrategyDecouples "how do I appear/disappear" from the stack manager. Two ship in v1; consumers can add their own.
VtaRenderStrategyClone a VisualTreeAsset into the container on push, remove it on pop. For sub-panel screens.
VtaDestination<TContext>Convenience destination bundling VtaRenderStrategy + a screen factory.
InScenePrefabStrategyActivate a pre-instantiated, disabled GameObject on push; fade out + deactivate on pop. For modal prefabs that live in the scene.
InSceneDestination<TContext>Convenience destination bundling InScenePrefabStrategy + a screen factory.

Two render strategies, two ways to be a screen

Pick the strategy that matches how your destination's view comes into the world:

If your destination is…StrategyScreen pattern
A VisualTreeAsset cloned into a container slot (e.g. detail panels in a single-shell scene)VtaRenderStrategyPlain C# class implementing INavigationScreen<TContext>, created fresh per push by a factory closure.
A MonoBehaviour already in the scene as a disabled GameObject (e.g. modal prefabs)InScenePrefabStrategyThe MonoBehaviour itself implements INavigationScreen<TContext>. No wrapper class needed.

Pattern A — VTA-cloned screen (plain class)

Used by Showcase's detail panels (HeroDetail, Profile, Inventory, etc.). Each push clones a fresh VisualElement tree; the screen is short-lived state holding wiring for that one mount.

csharp
public class HeroDetailScreen : INavigationScreen<HeroDetailContext> { public void OnEnter(VisualElement root, HeroDetailContext ctx, LunaNavigationHost nav) { root.Q<Label>("HeroName").text = ctx.Hero.Name; root.Q<Button>("Back").clicked += nav.Pop; // ... rest of UXML wiring } public void OnExit() { } } // Registration: nav.Register(new VtaDestination<HeroDetailContext>( _heroDetailVta, () => new HeroDetailScreen())); // fresh instance per push

Pattern B — In-scene prefab screen (MonoBehaviour implements interface)

Used by Showcase's 5 modals (Confirm, Auth, Input, LevelUp, MailDetail). The prefab is placed in the scene at design time as a disabled GameObject; its UIDocument + UIViewComponent already own the UXML wiring lifecycle. Adding two lifecycle methods makes the MonoBehaviour serve as its own navigation screen — no wrapper class:

csharp
public class ConfirmModalView : UIViewComponent, INavigationScreen<ConfirmModalView.Config> { public class Config { public string Title; public string Message; public Action OnConfirm; // ... } public void ApplyConfig(Config c) { // wire UXML to config (existing method) } void INavigationScreen<Config>.OnEnter(VisualElement _, Config ctx, LunaNavigationHost nav) { ApplyConfig(ctx); } void INavigationScreen<Config>.OnExit() { } } // Scene authoring: drag ConfirmModal.prefab into the scene, uncheck active. // Add a [SerializeField] GameObject _confirmModalGo on the shell controller. // Registration: nav.Register(new InSceneDestination<ConfirmModalView.Config>( _confirmModalGo, () => _confirmModalGo.GetComponent<ConfirmModalView>()));

The factory () => _confirmModalGo.GetComponent<ConfirmModalView>() returns the same MonoBehaviour instance every push — fine since the MB is persistent across pushes. The host's single-instance guard prevents double-pushing the same destination.

When to pick which: Pattern A for sub-panel content cloned out of a VisualTreeAsset; Pattern B for prefab-based modals that benefit from the persistent-GameObject lifecycle. A single shell can use both — Showcase does.

Lifecycle

nav.Push(new HeroDetailContext(card)) 1. Lookup destination dict by typeof(HeroDetailContext) 2. Single-instance guard: if HeroDetailContext is already on the stack, no-op 3. Hide previous top (display:none) unless new view is marked modal/sheet 4. strategy.Render(container) → returns mounted view 5. screen = destination.CreateScreen() 6. screen.OnEnter(view, context, this) — screen wires UXML to context 7. Push entry onto stack + register Esc handler via InputEscapeManager 8. Fire OnPushed event [player presses Esc, or some screen calls nav.Pop()] 1. Top entry's OnExit() runs 2. strategy.Dismiss(view) — removes from hierarchy (VTA) or fades out (in-scene prefab) 3. InputEscapeManager.PopWithoutExecute(escapeKey) clears the Esc binding 4. Previous top's display:flex restored 5. Fire OnPopped event

The host treats a destination's root as either replacing (default — hides parent) or overlaying (parent stays visible). Mark the UXML root with one of two classes to opt into overlay behavior:

xml
<ui:VisualElement class="base-modal"> <!-- centered card over a dim backdrop --> ... </ui:VisualElement> <ui:VisualElement class="bottom-sheet"> <!-- slide-up sheet from below --> ... </ui:VisualElement>

When the host detects either class in the new view, it leaves the previous top's display: flex so the modal / sheet layers visibly over it. No code wiring — pure USS contract.

Single-instance guard

Push<TContext>(ctx) is a no-op if a destination for TContext is already somewhere on the stack. Use PushReplacingTopOfSameType<TContext>(ctx) for the "tap a second item in a list while the first is open — replace rather than stack" flow. Check nav.IsActive<TContext>() before pushing to handle this gracefully.

Public API

csharp
public class LunaNavigationHost { public LunaNavigationHost(VisualElement container); public int StackDepth { get; } public event Action<Type> OnPushed; public event Action<Type> OnPopped; public void Register<TContext>(INavigationDestination<TContext> destination); public bool IsActive<TContext>(); public void Push<TContext>(TContext context); public void PushReplacingTopOfSameType<TContext>(TContext context); public void Pop(); public void PopToRoot(); }

Tab routing — LunaTabHost

Tab routing is orthogonal to push/pop. A typical shell uses both: one LunaNavigationHost for the detail-overlay stack + one LunaTabHost for the bottom-tab body.

csharp
var tabHost = new LunaTabHost(contentSlot) { Mode = TabHostMode.CloneOnSwitch // default }; tabHost.OnTabChanged += UpdateTabBarActive; tabHost.Register("home", new VtaTabDestination(_homeTabVta, onEnter: body => new HomeTab(_fxLib).Mount(body, _nav))); tabHost.Register("heroes", new VtaTabDestination(_heroesTabVta, onEnter: body => new HeroesTab().Mount(body, _nav))); // ... tabHost.SwitchTab("home");

Tab classes are plain classes wired via registration closures — there's no ITabScreen interface to implement. The closure captures whatever dependencies the tab body needs (navigation host, FX library, etc.) and Mount is just a method name convention.

Tab lifetime modes

TabHostMode controls how tab bodies are treated across switches:

ModeBehaviorWhen to pick
CloneOnSwitch (default)Clone the destination's view fresh on every switch; discard the previous tab's view entirely. Tab state resets on switch. Cheap memory, expensive per switch.Tab bodies are heavy (large lists); state should reset naturally.
KeepMountedClone each tab's view once on first visit, keep it mounted thereafter (display: none / flex on switch). First switch pays the clone cost; subsequent switches are instant. Tab state (scroll position, form data) persists.Settings panels with forms; inventory with per-tab filters that should persist.
PreMountAllMount every registered tab's view at first SwitchTab regardless of which tab. After that, every switch is just a display toggle. Highest memory; zero per-switch work.3–5-tab shells where switching is the dominant interaction and latency matters.

Settable at any time via tabHost.Mode = …, though changing modes mid-session may leave dangling mounted views — set at construction time and don't change for production use.

Public API

csharp
public class LunaTabHost { public LunaTabHost(VisualElement contentSlot); public TabHostMode Mode { get; set; } public string ActiveTab { get; } public int RegisteredTabCount { get; } public event Action<string> OnTabChanged; public void Register(string tabId, ITabDestination destination); public void SwitchTab(string tabId); public void Refresh(); // force-rebuild current tab }

Custom render strategies

If your destination needs to come into the world differently than VTA-clone (Addressables load, scene-baked prefab toggle, runtime construction), implement INavigationRenderStrategy:

csharp
public class AddressablesRenderStrategy : INavigationRenderStrategy { readonly string _addressKey; public AddressablesRenderStrategy(string key) { _addressKey = key; } public VisualElement Render(VisualElement container) { // Load a VisualTreeAsset via Addressables, clone into container. // ... } public void Dismiss(VisualElement view) { view.RemoveFromHierarchy(); // Release the Addressables handle. } }

Plug it into a custom INavigationDestination<TContext> and register as normal.

What's not in v1

These are intentional omissions; raise an issue if your game needs them:

  • Argument[] typeless payload — won't ship. C# generics + typed contexts are strictly better DX.
  • Local vs Global navigation actions — Unity App UI's concept. Push from anywhere works fine for most shells.
  • Deep linking (URL-style routing into a stack state) — future.
  • Persisted back-stack across scene loads — if your game needs it, serialize the stack state yourself.
  • Visual graph editor — designed in LUNA_NAVIGATION_DESIGN.md Phases 4+ but not yet implemented.

See also

  • Luna UI Manager — the singleton coordinator the host builds on
  • UI Actions — alternative for Esc-only modal-close flows that don't need a stack
  • Source: Runtime/Scripts/Navigation/ in the Luna package

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