Skills maui-shell-navigation
install
source · Clone the upstream repo
git clone https://github.com/dotnet/skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/dotnet/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/dotnet-maui/skills/maui-shell-navigation" ~/.claude/skills/dotnet-skills-maui-shell-navigation && rm -rf "$T"
manifest:
plugins/dotnet-maui/skills/maui-shell-navigation/SKILL.mdsource content
.NET MAUI Shell Navigation
Implement page navigation in .NET MAUI apps using Shell. Shell provides URI-based navigation, a flyout menu, tab bars, and a four-level visual hierarchy — all configured declaratively in XAML.
When to Use
- Setting up top-level app navigation with tabs or a flyout menu
- Navigating between pages programmatically with
GoToAsync - Passing data between pages via query parameters or object parameters
- Registering detail-page routes for push navigation
- Guarding navigation with confirmation dialogs (e.g., unsaved changes)
- Customizing back button behavior per page
When Not to Use
- Deep linking from external URLs or app links — see .NET MAUI deep linking docs
- Data binding on navigation target pages — use
maui-data-binding - Dependency injection for pages and view models — use
maui-dependency-injection - Apps using
without Shell (different navigation API)NavigationPage
Inputs
- A .NET MAUI project with
as the root shellAppShell.xaml - Pages (
) to navigate betweenContentPage - Route names for detail pages not in the visual hierarchy
Shell Visual Hierarchy
Shell uses a four-level hierarchy. Each level wraps the one below it:
Shell ├── FlyoutItem / TabBar (top-level grouping) │ ├── Tab (bottom-tab grouping) │ │ ├── ShellContent (page slot → ContentPage) │ │ └── ShellContent (multiple = top tabs) │ └── Tab └── FlyoutItem / TabBar
- FlyoutItem — appears in the flyout menu; contains
childrenTab - TabBar — bottom tab bar with no flyout entry
- Tab — groups
; multiple children produce top tabsShellContent - ShellContent — each points to a
ContentPage
Implicit Conversion
You can omit intermediate wrappers. Shell auto-wraps:
| You write | Shell creates |
|---|---|
only | |
only | |
in | |
Workflow: Set Up AppShell
- Define
inheriting fromAppShell.xamlShell - Add
orFlyoutItem
elements for top-level navigationTabBar - Add
elements for bottom tabs; nest multipleTab
for top tabsShellContent - Always use
withContentTemplate
so pages load on demandDataTemplate - Register detail-page routes in the
constructorAppShell
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:views="clr-namespace:MyApp.Views" x:Class="MyApp.AppShell" FlyoutBehavior="Flyout"> <FlyoutItem Title="Animals" Icon="animals.png"> <Tab Title="Cats"> <ShellContent Title="Domestic" ContentTemplate="{DataTemplate views:DomesticCatsPage}" /> <ShellContent Title="Wild" ContentTemplate="{DataTemplate views:WildCatsPage}" /> </Tab> <Tab Title="Dogs" Icon="dogs.png"> <ShellContent ContentTemplate="{DataTemplate views:DogsPage}" /> </Tab> </FlyoutItem> <TabBar> <ShellContent Title="Home" Icon="home.png" ContentTemplate="{DataTemplate views:HomePage}" /> <ShellContent Title="Settings" Icon="settings.png" ContentTemplate="{DataTemplate views:SettingsPage}" /> </TabBar> </Shell>
// AppShell.xaml.cs public partial class AppShell : Shell { public AppShell() { InitializeComponent(); Routing.RegisterRoute("animaldetails", typeof(AnimalDetailsPage)); Routing.RegisterRoute("editanimal", typeof(EditAnimalPage)); } }
Workflow: Navigate with GoToAsync
All programmatic navigation uses
Shell.Current.GoToAsync. Always await the call.
Route Prefixes
| Prefix | Meaning |
|---|---|
| Absolute route from Shell root |
| (none) | Relative; pushes onto the current nav stack |
| Go back one level |
| Go back then navigate forward |
Navigation Examples
// 1. Absolute — switch to a specific hierarchy location await Shell.Current.GoToAsync("//animals/cats/domestic"); // 2. Relative — push a registered detail page await Shell.Current.GoToAsync("animaldetails"); // 3. With query string parameters await Shell.Current.GoToAsync($"animaldetails?id={animal.Id}"); // 4. Go back one page await Shell.Current.GoToAsync(".."); // 5. Go back two pages await Shell.Current.GoToAsync("../.."); // 6. Go back one page, then push a different page await Shell.Current.GoToAsync("../editanimal");
Workflow: Pass Data Between Pages
Option 1: IQueryAttributable (Preferred)
Implement on ViewModels to receive all parameters in one call:
public class AnimalDetailsViewModel : ObservableObject, IQueryAttributable { public void ApplyQueryAttributes(IDictionary<string, object> query) { if (query.TryGetValue("id", out var id)) AnimalId = id.ToString(); } }
Option 2: QueryProperty Attribute
Apply directly on the page class:
[QueryProperty(nameof(AnimalId), "id")] public partial class AnimalDetailsPage : ContentPage { public string AnimalId { get; set; } }
Option 3: Complex Objects via ShellNavigationQueryParameters
Pass objects without serializing to strings:
var parameters = new ShellNavigationQueryParameters { { "animal", selectedAnimal } }; await Shell.Current.GoToAsync("animaldetails", parameters);
Receive via
IQueryAttributable:
public void ApplyQueryAttributes(IDictionary<string, object> query) { Animal = query["animal"] as Animal; }
Workflow: Guard Navigation
Use
GetDeferral() in OnNavigating for async checks (e.g., "save unsaved changes?"):
// In AppShell.xaml.cs protected override async void OnNavigating(ShellNavigatingEventArgs args) { base.OnNavigating(args); if (hasUnsavedChanges && args.Source == ShellNavigationSource.Pop) { var deferral = args.GetDeferral(); bool discard = await ShowConfirmationDialog(); if (!discard) args.Cancel(); deferral.Complete(); } }
Tab Configuration
Bottom Tabs
Multiple
ShellContent (or Tab) children inside a TabBar or FlyoutItem produce bottom tabs.
Top Tabs
Multiple
ShellContent children inside a single Tab produce top tabs:
<Tab Title="Photos"> <ShellContent Title="Recent" ContentTemplate="{DataTemplate views:RecentPage}" /> <ShellContent Title="Favorites" ContentTemplate="{DataTemplate views:FavoritesPage}" /> </Tab>
Tab Bar Appearance
| Attached Property | Type | Purpose |
|---|---|---|
| | Tab bar background |
| | Selected icon color |
| | Selected tab title color |
| | Unselected tab icon/title |
| | Show/hide the tab bar |
<!-- Hide the tab bar on a specific page --> <ContentPage Shell.TabBarIsVisible="False" ... />
Flyout Configuration
FlyoutBehavior
Set on
Shell: Disabled, Flyout, or Locked.
<Shell FlyoutBehavior="Flyout"> ... </Shell>
FlyoutDisplayOptions
Controls how children appear in the flyout:
(default) — one flyout entry for the groupAsSingleItem
— each childAsMultipleItems
gets its own entryTab
<FlyoutItem Title="Animals" FlyoutDisplayOptions="AsMultipleItems"> <Tab Title="Cats" ... /> <Tab Title="Dogs" ... /> </FlyoutItem>
MenuItem (Non-Navigation Flyout Entries)
<MenuItem Text="Log Out" Command="{Binding LogOutCommand}" IconImageSource="logout.png" />
Back Button Behavior
Customize the back button per page:
<Shell.BackButtonBehavior> <BackButtonBehavior Command="{Binding BackCommand}" IconOverride="back_arrow.png" TextOverride="Cancel" IsVisible="True" /> </Shell.BackButtonBehavior>
Properties:
Command, CommandParameter, IconOverride, TextOverride, IsVisible, IsEnabled.
Inspecting Navigation State
// Current URI location string location = Shell.Current.CurrentState.Location.ToString(); // Current page Page page = Shell.Current.CurrentPage; // Navigation stack of the current tab IReadOnlyList<Page> stack = Shell.Current.Navigation.NavigationStack;
Navigation Events
Override in
AppShell:
protected override void OnNavigated(ShellNavigatedEventArgs args) { base.OnNavigated(args); // args.Current, args.Previous, args.Source }
ShellNavigationSource values: Push, Pop, PopToRoot, Insert, Remove, ShellItemChanged, ShellSectionChanged, ShellContentChanged, Unknown.
Common Pitfalls
- Eager page creation: Using
directly instead ofContent
withContentTemplate
creates all pages at Shell init, hurting startup time. Always useDataTemplate
.ContentTemplate - Duplicate route names:
throwsRouting.RegisterRoute
if a route name matches an existing route or a visual hierarchy route. Every route must be unique across the app.ArgumentException - Relative routes without registration: You cannot
unlessGoToAsync("somepage")
was registered withsomepage
. Visual hierarchy pages use absoluteRouting.RegisterRoute
routes.// - Fire-and-forget GoToAsync: Not awaiting
causes race conditions and silent failures. AlwaysGoToAsync
the call.await - Wrong absolute route path: Absolute routes must match the full path through the visual hierarchy (
). Wrong paths produce silent no-ops, not exceptions.//FlyoutItem/Tab/ShellContent - Manipulating Tab.Stack directly: The navigation stack is read-only. Use
for all navigation changes.GoToAsync - Forgetting
for async guards: Synchronous cancellation inGetDeferral()
works, but async checks requireOnNavigating
/GetDeferral()
to avoid race conditions.deferral.Complete()
References
— Full API reference for Shell hierarchy, routes, tabs, flyout, and navigationreferences/shell-navigation-api.md- .NET MAUI Shell Navigation
- .NET MAUI Shell Tabs
- .NET MAUI Shell Flyout
- .NET MAUI Shell Pages