---
title: Navigation components
description: "Parameters, slots, and NavigationInfo bindings for the four Pennington.UI navigation components — TableOfContentsNavigation, OutlineNavigation, Breadcrumb, and Pagination."
canonical_url: https://usepennington.net/reference/ui/navigation/
sidecar_url: https://usepennington.net/reference/ui/navigation.md
content_hash: sha256:60db88c62444c216f521c37f82c9550a561294bf56fadb049d03c7d6fe8929ca
tokens: 6170
uid: reference.ui.navigation
reading_time_minutes: 6
---

Reference
# Navigation components

Parameters, slots, and NavigationInfo bindings for the four Pennington.UI navigation components — TableOfContentsNavigation, OutlineNavigation, Breadcrumb, and Pagination.

 
The four navigation-oriented Razor components in `Pennington.UI`. `TableOfContentsNavigation` and `OutlineNavigation` render, respectively, the sidebar page tree and the floating in-page heading outline, and live in namespace `Pennington.UI.Components.Navigation`. `Breadcrumb` and `Pagination` render the article-header trail and prev/numbered/next paging controls, and live in the base namespace `Pennington.UI.Components`. All four are consumed by `Pennington.DocSite`'s `MainLayout`.

 
## `TableOfContentsNavigation`

 
### Declaration

 
```razor:symbol
@inject IServiceProvider Services
@if (TableOfContents != null)
{
    <nav>
        <ul class="@_list">
            @foreach (var tableOfContentEntry in TableOfContents.OrderBy(i => i.Order))
            {
                @TocEntry(tableOfContentEntry)
            }
        </ul>
    </nav>
}
  
@code {
    /// <summary>Navigation tree to render; when null the component renders nothing, and entries are sorted by <see cref="NavigationTreeItem.Order"/> at each level.</summary>
    [Parameter] public ImmutableList<NavigationTreeItem>? TableOfContents { get; set; }
  
    /// <summary>Optional label forwarded from the caller's <c>NavigationInfo.SectionName</c>; not rendered by the default template.</summary>
    [Parameter] public string? SectionLabel { get; set; }
  
    /// <summary>Visual archetype: <see cref="TocVariant.Rail"/> (default) or <see cref="TocVariant.Pill"/>. A site template sets this once to pick a cohesive look.</summary>
    [Parameter] public TocVariant Variant { get; set; } = TocVariant.Rail;
  
    /// <summary>Classes Tailwind-merged over the variant's outer <c>&lt;ul&gt;</c> base.</summary>
    [Parameter] public string? ListClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the variant's per-section <c>&lt;li&gt;</c> base.</summary>
    [Parameter] public string? SectionClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the variant's section-label base (the <c>&lt;div&gt;</c> for empty-route entries, or the <c>&lt;a&gt;</c> when a top-level entry has children).</summary>
    [Parameter] public string? SectionTitleClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the variant's nested <c>&lt;ul&gt;</c> base that holds a section's child entries.</summary>
    [Parameter] public string? SectionListClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the variant's child-link base; the base also carries the <c>data-[current=true]</c> state styling.</summary>
    [Parameter] public string? LinkClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the variant's top-level leaf-link base; the base also carries the <c>data-[current=true]</c> state styling.</summary>
    [Parameter] public string? TopLinkClass { get; set; }
  
    // Optional: site templates register ClassMerge (from MonorailCssService.CreateClassMerger) so
    // a passed *Class param knocks out conflicting base utilities. Bare hosts get no service and
    // we append instead. Blazor's [Inject] has no optional mode, hence GetService.
    private ClassMerge? _merge;
  
    private string _list = "";
    private string _section = "";
    private string _sectionTitle = "";
    private string _sectionList = "";
    private string _link = "";
    private string _topLink = "";
  
    protected override void OnInitialized() => _merge = Services.GetService<ClassMerge>();
  
    protected override void OnParametersSet()
    {
        var slots = TocVariantStyles.For(Variant);
        _list = Apply(slots.List, ListClass);
        _section = Apply(slots.Section, SectionClass);
        _sectionTitle = Apply(slots.SectionTitle, SectionTitleClass);
        _sectionList = Apply(slots.SectionList, SectionListClass);
        _link = Apply(slots.Link, LinkClass);
        _topLink = Apply(slots.TopLink, TopLinkClass);
    }
  
    // A registered ClassMerge resolves conflicts; without one (bare host) we append, accepting
    // that conflicting base utilities are not removed.
    private string Apply(string baseClasses, string? extra)
        => _merge is not null
            ? _merge.Apply(baseClasses, extra)
            : string.IsNullOrEmpty(extra) ? baseClasses : $"{baseClasses} {extra}".Trim();
  
    private RenderFragment TocEntry(NavigationTreeItem tocEntry) =>
        @<li class="@_section">
            @if (tocEntry.Route.CanonicalPath.Value == "")
            {
                <div class="@_sectionTitle">@tocEntry.Title</div>
            }
            else
            {
                <a data-current="@tocEntry.IsSelected.ToString().ToLowerInvariant()" href="@tocEntry.Route.CanonicalPath.Value" class="@(tocEntry.Children.Count == 0 ? _topLink : _sectionTitle)">@tocEntry.Title</a>
            }
            @if (tocEntry.Children.Count > 0)
            {
                <ul class="@_sectionList">
                    @foreach (var childEntry in tocEntry.Children.OrderBy(i => i.Order).Where(i => i.Route.CanonicalPath.Value != ""))
                    {
                        <li>
                            <a data-current="@childEntry.IsSelected.ToString().ToLowerInvariant()" href="@childEntry.Route.CanonicalPath.Value" class="@_link">@childEntry.Title</a>
                        </li>
                    }
                </ul>
            }
        </li>;
}
```

 
Renders an ordered `<nav><ul>` of `NavigationTreeItem` entries, recursing one level into each entry's `Children` collection and sorting by `NavigationTreeItem.Order` at each level. Root entries with an empty `Route.CanonicalPath` render as plain section headers; entries with a path render as anchor links carrying `data-current="true"` when `IsSelected` is set.

 
### Parameters

 
Every `*Class` parameter defaults to `null`, meaning the component renders its built-in default for that element. A value you pass is Tailwind-merged over that default for the instance — conflicting utilities are replaced, the rest kept. `Variant` selects which built-in look those defaults come from.

 
    Name Type Default Description     `TableOfContents` `ImmutableList<NavigationTreeItem>?` `null` Navigation tree to render; when `null` the component renders nothing.   `SectionLabel` `string?` `null` Optional label forwarded from the caller's `NavigationInfo.SectionName`; not rendered by the default template.   `Variant` `TocVariant` `TocVariant.Rail` Built-in look: `Rail` (a bordered left rail) or `Pill` (rounded pill buttons with a tinted active state).   `ListClass` `string?` `null` The outer `<ul>` that holds the top-level navigation entries.   `SectionClass` `string?` `null` Each top-level `<li>`.   `SectionTitleClass` `string?` `null` A section's label — the plain `<div>` for empty-route entries, or the `<a>` when a top-level entry has children.   `SectionListClass` `string?` `null` The nested `<ul>` that holds a section's child entries.   `LinkClass` `string?` `null` Each child-level `<a>`, including its `data-current=true` state styling.   `TopLinkClass` `string?` `null` A top-level leaf `<a>` (an entry with no children), including its `data-current=true` state styling.    
### Binding

 
`TableOfContents` accepts an `ImmutableList<NavigationTreeItem>` produced by `await NavigationBuilder.BuildTreeAsync(items, currentPath, locale)`. It does not accept a `NavigationInfo`. `SectionLabel` is typically passed from `NavigationInfo.SectionName`. No `RenderFragment` slots.

 
### Example

 
```razor
@{
    var tree = await NavigationBuilder.BuildTreeAsync(items, currentPath, locale);
}
  
<TableOfContentsNavigation TableOfContents="tree" SectionLabel="@navigation.SectionName" />
```

 
## `OutlineNavigation`

 
### Declaration

 
```razor:symbol
@inject IServiceProvider Services
@if (!string.IsNullOrWhiteSpace(Title))
{
    <div class="@_title">@Title</div>
}
<div data-role="page-outline" data-content-selector="@ContentSelector" class="relative @_container">
    <div data-role="page-outline-highlighter" class="absolute opacity-0 @_marker"></div>
    <div>
        <ul class="@_list"
            data-outline-link-class="@_link"
            data-outline-nested-link-class="@_nestedLink">
            @* Outline links will be dynamically generated by JavaScript *@
        </ul>
    </div>
</div>
  
@code {
    /// <summary>CSS selector the client-side outline script queries to discover heading elements; must be non-empty for the outline to populate.</summary>
    [Parameter, EditorRequired] public string ContentSelector { get; set; } = "";
  
    /// <summary>Optional eyebrow rendered above the outline; pass an empty string to suppress.</summary>
    [Parameter] public string Title { get; set; } = "On this page";
  
    /// <summary>Classes Tailwind-merged over the eyebrow above the outline list.</summary>
    [Parameter] public string? TitleClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the outer <c>data-role="page-outline"</c> container; <c>relative</c> stays hardcoded for marker positioning.</summary>
    [Parameter] public string? ContainerClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the moving highlight bar; <c>absolute</c> and <c>opacity-0</c> stay hardcoded — the client script positions the bar and toggles its opacity.</summary>
    [Parameter] public string? MarkerClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the outline <c>&lt;ul&gt;</c>.</summary>
    [Parameter] public string? ListClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the link base, emitted as <c>data-outline-link-class</c> and applied by the client-side script to each generated <c>&lt;a&gt;</c>; the base also carries the <c>data-[selected=true]</c> state styling.</summary>
    [Parameter] public string? LinkClass { get; set; }
  
    /// <summary>Classes Tailwind-merged over the nested-link base, emitted as <c>data-outline-nested-link-class</c> and appended by the client-side script to nested (H3-level) outline links.</summary>
    [Parameter] public string? NestedLinkClass { get; set; }
  
    // Optional: site templates register ClassMerge so a passed *Class param knocks out conflicting
    // base utilities; bare hosts get no service and we append instead. Blazor's [Inject] has no
    // optional mode, hence GetService.
    private ClassMerge? _merge;
  
    private string _title = "";
    private string _container = "";
    private string _marker = "";
    private string _list = "";
    private string _link = "";
    private string _nestedLink = "";
  
    protected override void OnInitialized() => _merge = Services.GetService<ClassMerge>();
  
    // Defaults are inline literals (a method body, so an edit hot-reloads); the per-instance
    // *Class param merges over each.
    protected override void OnParametersSet()
    {
        _title = Apply("font-display text-[13px] font-semibold mb-3 text-base-600 dark:text-base-300", TitleClass);
        _container = Apply("border-l border-base-200 dark:border-base-800", ContainerClass);
        _marker = Apply("left-[-1px] w-[2px] rounded-sm bg-primary-600 dark:bg-primary-300 transition-all duration-500", MarkerClass);
        _list = Apply("list-none pl-4 text-base-500 dark:text-base-400", ListClass);
        _link = Apply("block py-1 ml-[calc(-1*(4em-1px))] pl-[calc(4em+1px)] transition-colors duration-150 hover:text-base-900 dark:hover:text-base-50 data-[selected=true]:text-primary-700 dark:data-[selected=true]:text-primary-300 data-[selected=true]:font-medium", LinkClass);
        _nestedLink = Apply("pl-4", NestedLinkClass);
    }
  
    // A registered ClassMerge resolves conflicts; without one (bare host) we append, accepting
    // that conflicting base utilities are not removed.
    private string Apply(string baseClasses, string? extra)
        => _merge is not null
            ? _merge.Apply(baseClasses, extra)
            : string.IsNullOrEmpty(extra) ? baseClasses : $"{baseClasses} {extra}".Trim();
}
```

 
Emits a `data-role="page-outline"` container and an empty `<ul>` whose items are populated client-side by scraping headings from the element matched by `ContentSelector`. The component performs no server-side heading extraction; the companion script in `Pennington.UI/wwwroot/` reads `data-content-selector`, `data-outline-link-class`, and `data-outline-nested-link-class` to build and highlight the outline in the browser.

 
### Parameters

 
`ContentSelector` is `[EditorRequired]`. Every `*Class` parameter defaults to `null`, meaning the component renders its built-in default for that element; a value you pass is Tailwind-merged over that default for the instance.

 
    Name Type Default Description     `ContentSelector` `string` `""` (required) CSS selector the client-side outline script queries to discover heading elements; must be non-empty for the outline to populate.   `Title` `string` `"On this page"` Eyebrow rendered above the outline list as a `<div>`; pass an empty string to suppress.   `TitleClass` `string?` `null` The eyebrow above the outline list.   `ContainerClass` `string?` `null` The outer `data-role="page-outline"` container; `relative` stays hardcoded for marker positioning.   `MarkerClass` `string?` `null` The moving highlight bar that tracks the active heading; `absolute` and `opacity-0` stay hardcoded — the script positions the bar and toggles its opacity.   `ListClass` `string?` `null` The outline `<ul>`.   `LinkClass` `string?` `null` Emitted as `data-outline-link-class` and applied by the client-side script to each generated `<a>`, including its `data-selected=true` state styling.   `NestedLinkClass` `string?` `null` Emitted as `data-outline-nested-link-class` and appended by the client-side script to nested (H3-level) outline links.    
### Binding

 
The component performs no server-side heading extraction. The outline list is populated at runtime by the companion client script in `Pennington.UI/wwwroot/`, which queries the element matched by `ContentSelector` and reads `data-content-selector`, `data-outline-link-class`, and `data-outline-nested-link-class` from the container. `NavigationInfo` is not consulted. No `RenderFragment` slots.

 
### Example

 
```razor
<OutlineNavigation ContentSelector="#main-content" Title="On this page" />
```

 
## `Breadcrumb`

 
### Declaration

 
```razor:symbol
@* Visible breadcrumb trail intended to live inside an article header.
   Pairs with the ImmutableList<BreadcrumbItem> exposed by
   Pennington.Navigation.NavigationInfo (built by NavigationBuilder).
   Use TrailingContent for an "Edit on GitHub" link or other right-aligned
   chrome on the same line. *@
@using System.Collections.Immutable
@using Pennington.Navigation
  
@if (Items.Count > 0)
{
    <nav class="flex items-center gap-2 font-display text-[12.5px] font-medium text-base-500 dark:text-base-400 min-w-0 flex-wrap" aria-label="Breadcrumb">
        @for (var i = 0; i < Items.Count; i++)
        {
            var item = Items[i];
            var isLast = i == Items.Count - 1;
            var url = item.Route?.CanonicalPath.Value;
            var hasUrl = !string.IsNullOrEmpty(url);
            if (hasUrl && !isLast)
            {
                <a href="@url" class="hover:text-base-900 dark:hover:text-base-50 transition-colors">@item.Title</a>
            }
            else if (isLast)
            {
                <span class="text-base-700 dark:text-base-200" aria-current="page">@item.Title</span>
            }
            else
            {
                <span>@item.Title</span>
            }
            if (!isLast)
            {
                <span class="text-base-300 dark:text-base-700" aria-hidden="true">/</span>
            }
        }
        @if (TrailingContent is not null)
        {
            <span class="ml-auto flex items-center gap-3">
                @TrailingContent
            </span>
        }
    </nav>
}
  
@code {
    /// <summary>The breadcrumb trail to render. Empty list renders nothing.</summary>
    [Parameter] public ImmutableList<BreadcrumbItem> Items { get; set; } = [];
  
    /// <summary>
    /// Optional content rendered on the trailing edge of the breadcrumb row
    /// (e.g. "Edit on GitHub" link). Pushed right via <c>ml-auto</c>.
    /// </summary>
    [Parameter] public RenderFragment? TrailingContent { get; set; }
}
```

 
Renders a visible breadcrumb trail inside an article header from the `ImmutableList<BreadcrumbItem>` `NavigationBuilder` exposes via `NavigationInfo`. Each item links to its route except the last (which renders as a current-page `<span aria-current="page">`); the trail renders nothing when the list is empty. `TrailingContent` supplies optional right-aligned chrome on the same row (an "Edit on GitHub" link, repository metadata).

 
### Parameters

 
    Name Type Default Description     `Items` `ImmutableList<BreadcrumbItem>` `[]` The breadcrumb trail to render. Empty list renders nothing.   `TrailingContent` `RenderFragment?` `null` Optional content rendered on the trailing edge of the breadcrumb row; pushed right via `ml-auto`.    
### Binding

 
`Items` accepts the `ImmutableList<BreadcrumbItem>` exposed as `NavigationInfo.Breadcrumbs`. The last item renders as the current page; every prior item with a `Route` renders as a link. `TrailingContent` is a `RenderFragment` slot for right-aligned chrome on the same row.

 
### Example

 
```razor
<Breadcrumb Items="navigation.Breadcrumbs">
    <TrailingContent>
        <a href="@editUrl">Edit on GitHub</a>
    </TrailingContent>
</Breadcrumb>
```

 
## `Pagination`

 
### Declaration

 
```razor:symbol
@* Prev / numbered / next pagination controls. Caller supplies the page-N URL via UrlFor —
   the component is URL-shape agnostic so it works for /archive/page/N/, /tags/{tag}/page/N/,
   /docs/page-N/, or anything else. Renders nothing when TotalPages <= 1. *@
  
@if (TotalPages > 1)
{
    <nav class="mt-12 flex items-center justify-between gap-4 border-t border-base-200 dark:border-base-800 pt-6" aria-label="Pagination">
        <div class="flex-1">
            @if (CurrentPage > 1)
            {
                <a href="@UrlFor(CurrentPage - 1)" rel="prev" class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-base-700 dark:text-base-200 hover:bg-base-100 dark:hover:bg-base-800 transition-colors">
                    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="h-4 w-4">
                        <path d="M9.25 5.75 6.75 8l2.5 2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
                    </svg>
                    Previous
                </a>
            }
        </div>
  
        <ol class="hidden sm:flex items-center gap-1">
            @foreach (var slot in BuildSlots())
            {
                @if (slot.IsGap)
                {
                    <li aria-hidden="true" class="px-2 text-sm text-base-400 dark:text-base-500">...</li>
                }
                else if (slot.Page == CurrentPage)
                {
                    <li>
                        <span aria-current="page" class="inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-semibold bg-primary-600 text-white dark:bg-primary-500">@slot.Page</span>
                    </li>
                }
                else
                {
                    <li>
                        <a href="@UrlFor(slot.Page)" class="inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium text-base-700 dark:text-base-200 hover:bg-base-100 dark:hover:bg-base-800 transition-colors">@slot.Page</a>
                    </li>
                }
            }
        </ol>
  
        <p class="sm:hidden text-sm text-base-500 dark:text-base-400" aria-live="polite">Page @CurrentPage of @TotalPages</p>
  
        <div class="flex-1 flex justify-end">
            @if (CurrentPage < TotalPages)
            {
                <a href="@UrlFor(CurrentPage + 1)" rel="next" class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-base-700 dark:text-base-200 hover:bg-base-100 dark:hover:bg-base-800 transition-colors">
                    Next
                    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="h-4 w-4">
                        <path d="M6.75 5.75 9.25 8l-2.5 2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
                    </svg>
                </a>
            }
        </div>
    </nav>
}
  
@code {
    /// <summary>1-based page index for the current view. Highlighted in the numbered list.</summary>
    [Parameter] public int CurrentPage { get; set; } = 1;
  
    /// <summary>Total number of pages. The component renders nothing when this is 1 or less.</summary>
    [Parameter] public int TotalPages { get; set; } = 1;
  
    /// <summary>
    /// Returns the URL for a given 1-based page index. The component never calls this with
    /// the current page, but callers should be prepared to map page <c>1</c> to the canonical
    /// (non-paginated) URL of the listing.
    /// </summary>
    [Parameter] public Func<int, string> UrlFor { get; set; } = page => $"?page={page}";
  
    /// <summary>
    /// Number of numeric page links flanking the current page in the truncated list. The
    /// first and last pages are always rendered; gaps between them and the window collapse
    /// to "...". Default of 1 yields windows like <c>1 ... 4 5 6 ... 12</c>.
    /// </summary>
    [Parameter] public int SiblingCount { get; set; } = 1;
  
    private IEnumerable<Slot> BuildSlots()
    {
        if (TotalPages <= 1)
        {
            yield break;
        }
  
        var lo = Math.Max(2, CurrentPage - SiblingCount);
        var hi = Math.Min(TotalPages - 1, CurrentPage + SiblingCount);
  
        yield return new Slot(1, false);
  
        if (lo > 2)
        {
            yield return new Slot(0, true);
        }
  
        for (var p = lo; p <= hi; p++)
        {
            yield return new Slot(p, false);
        }
  
        if (hi < TotalPages - 1)
        {
            yield return new Slot(0, true);
        }
  
        if (TotalPages > 1)
        {
            yield return new Slot(TotalPages, false);
        }
    }
  
    private readonly record struct Slot(int Page, bool IsGap);
}
```

 
Prev / numbered / next pagination controls. URL-pattern agnostic — the caller supplies a `Func<int, string>` that maps a 1-based page index to a URL, so the same component drives `/archive/page/N/`, `/tags/{tag}/page/N/`, or any other pattern. Renders nothing when `TotalPages` is 1 or less.

 
### Parameters

 
    Name Type Default Description     `CurrentPage` `int` `1` 1-based page index for the current view; highlighted in the numbered list.   `TotalPages` `int` `1` Total number of pages. The component renders nothing when this is 1 or less.   `UrlFor` `Func<int, string>` `page => "?page={page}"` Returns the URL for a given 1-based page index. Callers should map page 1 to the canonical (non-paginated) URL of the listing.   `SiblingCount` `int` `1` Number of numeric page links flanking the current page in the truncated list. The first and last pages are always rendered; gaps collapse to `...`. Default of 1 yields windows like `1 ... 4 5 6 ... 12`.    
### Binding

 
`Pagination` does not consult `NavigationInfo`. The caller supplies `CurrentPage` and `TotalPages` as plain integers and maps each 1-based page index to a URL through the `UrlFor` delegate, so the same component drives any paging URL shape. No `RenderFragment` slots.

 
### Example

 
```razor
@{
    string PageUrl(int page) => page == 1 ? "/archive/" : $"/archive/page/{page}/";
}
  
<Pagination CurrentPage="currentPage" TotalPages="totalPages" UrlFor="PageUrl" />
```

 
## See also

 
 - How-to: [Customize the sidebar](https://usepennington.net/how-to/navigation/customize-sidebar.md)
 - Related reference: [Navigation types](https://usepennington.net/reference/api/navigation-builder.md)
 - Related reference: [Content components](https://usepennington.net/reference/ui/content.md)
 
 
[Previous
                
                Code-block argument reference](https://usepennington.net/reference/markdown/code-block-args.md)[Next
                    
                Content components](https://usepennington.net/reference/ui/content.md)