🧠This is Luna's custom
CupkekGames.Luna.TabView— not Unity's built-inTabView(Luna restyles that one separately with.unity-tab-view.{color}classes; see Colors).
TabView is a VisualElement that renders a headless tab strip — a row of buttons with active-class management, optional auto-routing to Luna nav channels, an OnTabChanged(string) event, and optional gamepad prev/next shortcuts.
It owns no body content. Tab bodies live either as separate nav channels (NavNode ids, swapped by LunaNavigation.SwitchChannel when Destinations is configured) or as plain sibling panels the TabView display-toggles in Panels mode. The channel form mirrors how Luna's bottom-nav showcase routes the five main tabs.
Earlier TabView shipped a <luna:Tab> wrapper that owned content + auto-built button headers + custom navigation. That collapsed three responsibilities (tab selection, button styling, content display) into one element. The headless shape pulls them apart: TabView is just the tab-id selector + router. Content lives elsewhere — in nav channels (Channels mode) or in plain sibling elements it only display-toggles (Panels mode). Styling is plain USS on plain <ui:Button> children.
The mode attribute (TabViewMode) decides what tab activation does. Auto (the default) infers from config, so most authors never set it:
| Mode | Behavior on tab activation |
|---|---|
Auto | Infers: Channels if Destinations has entries, else Panels. |
Channels | Navigation: SwitchChannel to the matching Destinations entry (scope-aware). |
Panels | View-state: display-toggle local sibling tab-panels by name. |
Events | Neither — only raises OnTabChanged for a custom handler. |
DestinationsUXML — a TabView plus child Buttons whose name is the tab id, with the channel NavNode ids comma-separated in the destinations attribute (DOM order matters — entry [i] is routed when tab i activates). This is the showcase's bottom bar (TabBar.uxml):
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:luna="CupkekGames.Luna">
<luna:TabView name="BottomTabs" default-active="Home"
destinations="home.tab.home,home.tab.heroes,home.tab.quests,home.tab.shop,home.tab.leaderboard">
<ui:Button name="Home" text="Home" />
<ui:Button name="Heroes" text="Heroes" />
<ui:Button name="Quests" text="Quests" />
<ui:Button name="Shop" text="Shop" />
<ui:Button name="Leaderboard" text="Ranks" />
</luna:TabView>
</ui:UXML>That's it. On each tab activation TabView calls LunaNavigation.SwitchChannel(Destinations[i]) and the nav system swaps the visible channel. Null/empty entries or an empty list = no routing for that tab (the OnTabChanged event still fires).
Sync runs both ways: a Channels-mode TabView subscribes to the nav stack's channel-changed event, so a programmatic LunaNavigation.SwitchChannel (or a Pop that reactivates a channel) updates the --active class silently — no OnTabChanged re-fire, no route loop.
Channels nest, and so do TabViews — a tab body can host its own TabView routing to sub-channels. The showcase's Shop tab does exactly this (ShopTab.uxml):
<luna:TabView name="ShopTabs" default-active="ShopBundlesButton"
destinations="home.tab.shop.bundle,home.tab.shop.gold,home.tab.shop.items">
<ui:Button name="ShopBundlesButton" text="Bundles" />
<ui:Button name="ShopGoldPacksButton" text="Gold Packs" />
<ui:Button name="ShopItemsButton" text="Items" />
</luna:TabView>Routing is scope-aware: a nested TabView only drives global navigation while its owning channel (here home.tab.shop) is on the active path. While dormant — e.g. at boot, when every tab body attaches at once — it sets its visual ActiveTab but defers the SwitchChannel, then re-applies the route when its channel activates. No hijacking, no per-consumer guards.
When no Destinations are wired, the TabView itself display-toggles local content panels: every element carrying the tab-panel USS class (override via panel-class) whose name matches a tab button's name. Only the active panel gets display: flex; the rest get display: none.
<ui:VisualElement>
<luna:TabView default-active="Stats">
<ui:Button name="Stats" text="Stats" />
<ui:Button name="Gear" text="Gear" />
</luna:TabView>
<ui:VisualElement name="Stats" class="tab-panel">...</ui:VisualElement>
<ui:VisualElement name="Gear" class="tab-panel">...</ui:VisualElement>
</ui:VisualElement>Panel lookup walks up from the TabView's parent to the smallest ancestor that contains matching panels, so the panels can be siblings or live higher in the view (left-rail tabs, right-rail panel stack). Set panels-root to an element name to search a specific container instead. Panels mode with no matching panels degrades to events-only.
OnTabChangedSet mode="Events" and listen to the event directly. Useful when the swap target is neither a nav channel nor a local panel:
var tv = root.Q<TabView>("MyTabs");
tv.OnTabChanged += tabId =>
{
// do whatever swap / data-load / analytics you want
};OnTabChanged doesn't replay the initial activation for late subscribers — TabView fires it during AttachToPanel, usually before consumer code can subscribe. AddTabChangedListener is the canonical wiring for "react to changes AND sync to the current state right now":
tv.AddTabChangedListener(tabId =>
{
RefreshBody(tabId); // invoked immediately with the current ActiveTab, then on every change
});| Attribute | Type | Description |
|---|---|---|
default-active | string | Tab id (button name) to mark active on attach. Empty = first registered tab. |
mode | TabViewMode | Auto (default), Channels, Panels, or Events. Auto infers: Channels when destinations has entries, else Panels. |
destinations | List<string> (comma-separated) | Channel-root NavNode ids ordered to match tab-button DOM order. Entry [i] routes via LunaNavigation.SwitchChannel when tab i activates. Empty = no routing. |
panel-class | string | USS class of the local content panels toggled in Panels mode. Default "tab-panel". |
panels-root | string | Element name to search for panels. Empty (default) = walk up from the TabView's parent. |
prev-action | string | InputAction name for the global "previous tab" shortcut. Wired via InputDeviceManager.PlayerInputs[0]. Empty disables. Default "UI/Previous". |
next-action | string | Same as above for "next tab". Default "UI/Next". |
| Member | Purpose |
|---|---|
string ActiveTab { get; } | Current tab id. |
event Action<string> OnTabChanged | Fires on successful activation. No re-fire on click of already-active tab. |
void AddTabChangedListener(Action<string> handler) | Subscribe to OnTabChanged AND immediately invoke once with the current ActiveTab (if any) — the canonical "react + sync now" wiring. |
void SetActiveTab(string tabId) | Activate + fire. No-op if already active. |
void SetActiveTabSilent(string tabId) | Activate without firing the event or routing through navigation (Panels-mode visibility still syncs) — for external state sync (deep links). |
void GoPrevious() / void GoNext() | Activate the previous/next tab in DOM order, wrapping around. Fires OnTabChanged. |
int TabCount { get; } | Number of registered tab buttons. Zero before AttachToPanel completes. |
int GetTabIndex(string tabId) | DOM-order index of a tab id, or -1. Bridges string ids to index-based collections. |
Button GetTabButton(string tabId) | The registered tab Button for an id, or null — drive focus, hide individual buttons, etc. |
Focus ring (free). Every tab header is a real Button, so Tab/Shift+Tab/Arrow keys/gamepad-Submit work without any extra wiring. Style :focus via USS.
Global LB/RB shortcut (load-bearing). PrevAction and NextAction subscribe PlayerInput.actions[…].performed to GoPrevious/GoNext. Fires regardless of where focus currently is — the player can cycle tabs while focused on a slider inside a tab body.
Optional gamepad glyphs (opt-in). Drop a <luna:InputPrompt> descendant with class tab-bar__prev or tab-bar__next to show visible LB/RB hints at the strip ends. TabView wires the prompt's clicked to GoPrevious/GoNext as a focus+Submit fallback; the prompt's own HideIf attribute handles "hide on keyboard-mouse".
<luna:TabView default-active="A">
<luna:InputPrompt class="tab-bar__prev" input-action-name="UI/Previous" />
<ui:Button name="A" text="A" />
<ui:Button name="B" text="B" />
<ui:Button name="C" text="C" />
<luna:InputPrompt class="tab-bar__next" input-action-name="UI/Next" />
</luna:TabView>| Class | Targets |
|---|---|
tab-bar | Root. |
tab-bar__item | Each tab button (auto-added at attach). |
tab-bar__item--active | Active tab button. |
Style as you like — the package ships no USS for these. See the bottom-nav showcase's TabBar.uss for an example lift-on-active treatment (the matching markup is TabBar.uxml in the same folder).
NavNode channels, LunaNavigation.SwitchChannel, channel stacksSettings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction