Maui shell navigation
Skill dotnet/skills/plugins/dotnet-maui/skills/maui-shell-navigation
Repository for skills to assist AI coding agents with .NET and C#
npx -y skills add dotnet/skills --skill maui-shell-navigationAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
What its author says it does
Copied from the file, not written here
Guide for implementing Shell-based navigation in .NET MAUI apps. Covers AppShell setup, visual hierarchy (FlyoutItem, TabBar, Tab, ShellContent), URI-based navigation with GoToAsync, route registration, query parameters, back navigation, flyout and tab configuration, navigation events, and navigation guards. Use when: setting up Shell navigation, adding tabs or flyout menus, navigating between pages with GoToAsync, passing parameters between pages, registering routes, customizing back button behavior, or guarding navigation with confirmation dialogs. Do not use for: deep linking from external URLs (see .NET MAUI deep linking documentation), data binding on pages (use maui-data-binding), dependency injection setup (use maui-dependency-injection), or NavigationPage-only apps that don't use Shell.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
15.0 KB, as published. Nobody here has run it
.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
NavigationPagewithout Shell (different navigation API)
Inputs
- A .NET MAUI project with
AppShell.xamlas the root shell - Pages (
ContentPage) to navigate between - Route names for detail pages not in the visual hierarchy
Rules That Change the Answer
These are the Shell-specific decisions that are easy to get wrong. Apply them whenever they are relevant to what the user asked.
| Situation | Do this | Not this |
|---|---|---|
Declaring pages in AppShell.xaml | With xmlns:views="clr-namespace:MyApp.Views" declared: <ShellContent ContentTemplate="{DataTemplate views:MyPage}" /> — the page is created on first navigation | <ShellContent><views:MyPage /></ShellContent>, which constructs every page at startup |
| Navigating to a page not in the visual hierarchy | Routing.RegisterRoute("details", typeof(DetailsPage)) first | Calling GoToAsync("details") unregistered — it throws at runtime |
| Receiving navigation parameters | Implement IQueryAttributable on the ViewModel | Implementing it on the Page, which splits state from the BindingContext |
| Passing a whole object | ShellNavigationQueryParameters | Serialising the object into the query string |
Any GoToAsync call | await it | Fire-and-forget — exceptions are swallowed and navigation races |
| Confirming before back navigation | ShellNavigatingEventArgs.GetDeferral() … deferral.Complete() | Blocking synchronously on the dialog task |
| Detecting back navigation | Check e.Source == ShellNavigationSource.Pop | Assuming every navigation is a back action |
Do not propose NavigationPage / PushAsync solutions for a Shell app, and do
not restructure a working AppShell hierarchy unless the user asked.
Answer narrowly, but completely. Staying on topic does not mean being terse. When
you show a navigation change, include the pieces needed to run it: the AppShell.xaml
markup and the Routing.RegisterRoute call, or the GoToAsync call and the
receiving IQueryAttributable / [QueryProperty] code. Where two approaches are both
valid (query string vs ShellNavigationQueryParameters), show both and say when each
fits — a single snippet the user still has to complete is a worse answer.
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
Tabchildren - TabBar — bottom tab bar with no flyout entry
- Tab — groups
ShellContent; multiple children produce top tabs - ShellContent — each points to a
ContentPage
Implicit Conversion
You can omit intermediate wrappers. Shell auto-wraps:
| You write | Shell creates |
|---|---|
ShellContent only | FlyoutItem > Tab > ShellContent |
Tab only | FlyoutItem > Tab |
ShellContent in TabBar | TabBar > Tab > ShellContent |
Workflow: Set Up AppShell
- Define
AppShell.xamlinheriting fromShell - Add
FlyoutItemorTabBarelements for top-level navigation - Add
Tabelements for bottom tabs; nest multipleShellContentfor top tabs - Always use
ContentTemplatewithDataTemplateso pages load on demand - Give every
ShellContentan explicitRoute(see below) - Register detail-page routes in the
AppShellconstructor
Set
Route=on everyShellContent. If you omit it, MAUI auto-generates a name from a shared counter —Routing.csproducesD_FAULT_{TypeName}{n}. A real shell with three unnamedShellContentelements yields routes likeD_FAULT_ShellContent2andD_FAULT_ShellContent5: the numbers are not sequential, they depend on how many Shell elements were constructed first, and they shift when you reorder or add pages. You cannot write a stable absolute route (//dashboard) or deep link against that. An explicitRoute="dashboard"is stable forever.
<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" Route="domesticcats"
ContentTemplate="{DataTemplate views:DomesticCatsPage}" />
<ShellContent Title="Wild" Route="wildcats"
ContentTemplate="{DataTemplate views:WildCatsPage}" />
</Tab>
<Tab Title="Dogs" Icon="dogs.png">
<ShellContent Route="dogs" ContentTemplate="{DataTemplate views:DogsPage}" />
</Tab>
</FlyoutItem>
<TabBar>
<ShellContent Title="Home" Icon="home.png" Route="home"
ContentTemplate="{DataTemplate views:HomePage}" />
<ShellContent Title="Settings" Icon="settings.png" Route="settings"
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 on the ViewModel class (or the page, if it genuinely owns the state).
Prefer IQueryAttributable on the ViewModel — it keeps navigation state with the
BindingContext and handles multiple parameters in one call:
[QueryProperty(nameof(AnimalId), "id")]
public partial class AnimalDetailsViewModel : ObservableObject
{
[ObservableProperty]
private string _animalId = string.Empty;
}
Shell applies query attributes after the page constructor sets BindingContext,
so the property must raise change notification — a plain auto-property leaves the
binding stuck on its initial value.
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 |
|---|---|---|
Shell.TabBarBackgroundColor | Color | Tab bar background |
Shell.TabBarForegroundColor | Color | Selected icon color |
Shell.TabBarTitleColor | Color | Selected tab title color |
Shell.TabBarUnselectedColor | Color | Unselected tab icon/title |
Shell.TabBarIsVisible | bool | 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:
AsSingleItem(default) — one flyout entry for the groupAsMultipleItems— each childTabgets its own entry
<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
Contentdirectly instead ofContentTemplatewithDataTemplatecreates all pages at Shell init, hurting startup time. Always useContentTemplate. - Duplicate route names:
Routing.RegisterRoutethrowsArgumentExceptionif a route name matches an existing route or a visual hierarchy route. Every route must be unique across the app. - Relative routes without registration: You cannot
GoToAsync("somepage")unlesssomepagewas registered withRouting.RegisterRoute. Visual hierarchy pages use absolute//routes. - Fire-and-forget GoToAsync: Not awaiting
GoToAsynccauses race conditions and silent failures. Alwaysawaitthe call. - Wrong absolute route path: Absolute routes must match the full path through the visual hierarchy (
//FlyoutItem/Tab/ShellContent). Wrong paths produce silent no-ops, not exceptions. - Manipulating Tab.Stack directly: The navigation stack is read-only. Use
GoToAsyncfor all navigation changes. - Forgetting
GetDeferral()for async guards: Synchronous cancellation inOnNavigatingworks, but async checks requireGetDeferral()/deferral.Complete()to avoid race conditions.
References
references/shell-navigation-api.md— Full API reference for Shell hierarchy, routes, tabs, flyout, and navigation- .NET MAUI Shell Navigation
- .NET MAUI Shell Tabs
- .NET MAUI Shell Flyout
- .NET MAUI Shell Pages