🧭 This is Luna's custom CupkekGames.Luna.TabView — not Unity's built-in TabView (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.

Why headless?

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.

Modes

The mode attribute (TabViewMode) decides what tab activation does. Auto (the default) infers from config, so most authors never set it:

ModeBehavior on tab activation
AutoInfers: Channels if Destinations has entries, else Panels.
ChannelsNavigation: SwitchChannel to the matching Destinations entry (scope-aware).
PanelsView-state: display-toggle local sibling tab-panels by name.
EventsNeither — only raises OnTabChanged for a custom handler.

Setup — channel routing via Destinations

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

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

Nested sub-tabs

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

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

Setup — local panels (Panels mode, zero code)

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.

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

Setup — manual routing via OnTabChanged

Set mode="Events" and listen to the event directly. Useful when the swap target is neither a nav channel nor a local panel:

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

csharp
tv.AddTabChangedListener(tabId => { RefreshBody(tabId); // invoked immediately with the current ActiveTab, then on every change });

Inspector (UxmlAttributes)

AttributeTypeDescription
default-activestringTab id (button name) to mark active on attach. Empty = first registered tab.
modeTabViewModeAuto (default), Channels, Panels, or Events. Auto infers: Channels when destinations has entries, else Panels.
destinationsList<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-classstringUSS class of the local content panels toggled in Panels mode. Default "tab-panel".
panels-rootstringElement name to search for panels. Empty (default) = walk up from the TabView's parent.
prev-actionstringInputAction name for the global "previous tab" shortcut. Wired via InputDeviceManager.PlayerInputs[0]. Empty disables. Default "UI/Previous".
next-actionstringSame as above for "next tab". Default "UI/Next".

API

MemberPurpose
string ActiveTab { get; }Current tab id.
event Action<string> OnTabChangedFires 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.

Input navigation — three independent layers

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

xml
<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>

USS classes

ClassTargets
tab-barRoot.
tab-bar__itemEach tab button (auto-added at attach).
tab-bar__item--activeActive 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).

See also

  • Navigation — NavNode channels, LunaNavigation.SwitchChannel, channel stacks
  • InputPrompt — drives the visible gamepad prev/next glyphs
  • Pagination — alternative for paged data

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