Skip to content

Navigation - In Page

The Navigation - In Page feature (featureNavigationInPage) provides an in-page table of contents that links to specific feature blocks on the current page. It includes sticky positioning, collapsible mobile navigation, and ScrollSpy active state highlighting.

Editors pick which feature blocks to include using a Contentment Data List picker in the backoffice. The feature renders a Bootstrap card with anchor links to those blocks. As the user scrolls, ScrollSpy highlights the currently visible section.

The feature renders as a Bootstrap card with a header, toggler button, and collapsible link list:

<nav id="nav-in-page-{key}" class="card {sticky-nav if enabled}">
<header class="card-header d-flex justify-content-between align-items-center">
<h2 class="card-title mb-0">On this page</h2>
<button class="navbar-toggler d-md-none" data-bs-toggle="collapse" ...>
<span class="navbar-toggler-icon"></span>
</button>
</header>
<div class="collapse d-md-block" id="n{key}">
<ul class="list-group list-group-flush nav">
<li><a href="#feature-{contentKey}" class="list-group-item ...">Section Title</a></li>
...
</ul>
</div>
</nav>

Key points:

  • Links use #feature-{ContentKey} anchors matching the id attributes on feature blocks (set by _Layout_Features.cshtml)
  • The card renders directly without _Layout_Features.cshtml — it has no title/description/summary compositions
  • Uses the n + ContentKey (no hyphens) pattern for Bootstrap component IDs, consistent with FAQs and Tabs

On mobile (below md), the nav list is hidden by default using Bootstrap’s collapse component. A navbar toggler button appears in the card header for expand/collapse.

On desktop (md and above), the list is always visible via d-md-block which overrides the collapse hidden state. The toggler button is hidden via d-md-none.

The collapsing <div> has no padding — padding is on the inner <ul> via list-group-flush. This prevents the padding jump that occurs when Bootstrap animates the height of an element with padding.

An inline <script> initialises Bootstrap ScrollSpy on the page body, targeting the nav by its unique id. As the user scrolls, the .active class is applied to the link matching the currently visible section.

new bootstrap.ScrollSpy(document.body, {
target: '#nav-in-page-{key}',
rootMargin: '0px 0px -75%'
});

The rootMargin value uses Intersection Observer (Bootstrap 5.2+) — a section becomes active when it enters the top 25% of the viewport. The deprecated offset option is no longer used.

ScrollSpy is only initialised when the Navigation - In Page feature is present — the script lives inside the feature’s conditional @if block.

Sticky behaviour is editor-controlled via the featureSettingsNavigation settings type. Both featureNavigationInPage and featureNavigationDescendants use this settings type, which includes an Enable Sticky toggle (featureSettingsEnableSticky).

When enabled, the view adds the .sticky-nav CSS class to the <nav> element:

@{
var enableSticky = Model.Settings?.Value<bool>("featureSettingsEnableSticky") ?? false;
}
<nav class="card @(enableSticky ? "sticky-nav" : "")">

The default value for new navigation blocks is on (the Feature Settings Component - Enable Sticky DataType defaults to true). Editors can turn it off per-block if needed.

Sticky behaviour is content-driven using the CSS :has() pseudo-class. The .sticky-nav class on the nav element causes the containing area to become sticky — no Bootstrap utility classes on the area required.

A --navbar-height CSS custom property (defined on :root in index.scss) centralises the sticky offset value. This same variable is used for scroll-margin-top on anchor targets.

The .sticky-nav class on the <nav> element triggers two CSS rules via :has():

// Desktop (md+): .feature-items wrapper becomes sticky inside the area
[class*="area-"]:has(.sticky-nav) .feature-items {
@media (min-width: 768px) {
position: sticky;
top: var(--navbar-height);
}
}
// Mobile (below md): the area itself becomes sticky when columns stack
[class*="area-"]:has(.sticky-nav) {
@media (max-width: 767.98px) {
position: sticky;
top: var(--navbar-height);
z-index: 1020;
}
}

The sticky target differs by breakpoint because the HTML hierarchy changes:

  • Desktop: Areas sit side-by-side in a CSS grid row. The feature-items div is the right container to make sticky — it scrolls within the tall area.
  • Mobile: Areas stack vertically. The area div is the right container — content below it gives it scroll distance.

The :has() approach has several advantages:

  1. Content-driven: Sticky behaviour follows the navigation feature, not the container. If you move the nav to a different area or layout, sticky follows automatically.
  2. No editor configuration: Editors don’t need to add CSS classes to areas in the backoffice.
  3. Layout-agnostic: Works on any layout type, not just layout363.
  4. Future-proof: Avoids building workflows around adding Bootstrap classes to areas, which may change in future Umbraco versions.

Browser support for :has() is excellent — all modern browsers since late 2023.

Override the --navbar-height custom property if your navbar height differs from the default 7rem:

:root {
--navbar-height: 5rem; // your navbar height
}

This automatically updates sticky positioning and scroll anchor offsets throughout the site.

The picker uses a custom FeatureBlockDataSource (C# class implementing IContentmentDataSource) that scans all block grid properties on the current document and returns feature blocks as selectable items.

  1. Editor opens the Navigation - In Page block in the backoffice
  2. Contentment sends a POST request to its API with the current document’s GUID in the request body
  3. FeatureBlockDataSource reads the GUID, fetches the document, scans all properties for BlockGridModel values
  4. Collects feature blocks from every block grid found, grouped by grid property alias
  5. Returns all feature blocks (with titles) as selectable items
  6. Program.cs has EnableBuffering() middleware for Contentment API paths so the request body can be read

The DataSource does not hardcode any block grid property alias. It dynamically discovers all BlockGridModel properties on the current page by iterating content.Properties. This means:

  • Pages with a single block grid (e.g. contentGrid) work automatically
  • Pages with multiple block grids (e.g. contentGrid + headerGrid) show features from all grids
  • No code changes needed when adding new block grid properties to a document type

Each DataListItem includes a Group property set to the grid’s property alias. Contentment’s Item Picker does not currently render group headings, but the data is ready for when it does.

The view (featureNavigationInPage.cshtml) uses the same approach — it collects all BlockGridModel properties and searches across all of them to resolve the selected content keys at render time.

featureNavigationInPage (main feature block)
Compositions:
- featureComponentNavigationInPage
-> featurePropertyNavigationInPageItems (Contentment Data List)
-> FeatureBlockDataSource (custom C# data source)
-> Item Picker (list editor)

Note: This feature does not compose the standard featureComponentFeatureTitle, featureComponentFeatureDescription, or featureComponentFeatureSummary. It renders its own card directly.

  • Auto-collapse on click: Close the mobile collapse when a nav link is tapped
  • Picker filtering: Filter which feature blocks appear in the Contentment picker
  • Picker grouping: Contentment Item Picker does not currently render Group headings (the configuration editor modal does via Object.groupBy()). Feature request to Contentment to add group support to Item Picker
  • Multi-step picker: Custom property editor with step 1 (pick grid + area) and step 2 (pick features within that selection) for better UX on complex pages