Collaborate, Innovate, Automate

Building a Hierarchical Search Refiner in PnP Modern Search

PnP Modern Search refiners have always had the limitation that they only showed a flat list. Tag your content with taxonomy items, such as Department, Topic or whatever the term might be, and the refiner panel flattened it into one long alphabetical list of checkboxes, parents and children sitting side by side with no indication either was ever related to the other.

PnP Modern Search's Hierarchical Refiner template fixes this properly, letting a taxonomy render as an actual tree, expandable, collapsible, with parent selection pulling in everything beneath it. It's been a community request for years, and it's genuinely good once it's working. Getting working and styled is the subject of this article.

Why a Tree Beats a Flat List Here

Picture a taxonomy with a dozen top-level categories, each with multiple children underneath, Administrative Paperwork containing Templates and Forms, say. A flat refiner shows all of that as one undifferentiated list a visitor has to scan top to bottom, with no sense of which values are related.

A hierarchical refiner lets someone browse the way they'd actually think about the category, start broad, expand only the branch that's relevant, and select either a specific child term or the parent itself. Selecting the parent is the real payoff: it doesn't just filter to content tagged with that exact term, it pulls in everything tagged with any of its children too, one click for the whole category rather than ticking every child box individually.

What Has to Be in Place First

Two things need to exist before the Hierarchical Refiner template has anything to work with, and both are covered in full in the multilingual search series, worth reading first if this is new territory:

  1. A managed metadata column bound to a term set with real parent/child structure
  2. That column's term-ID crawled property (ows_taxId_YourColumn, not the plain label property) mapped to a RefinableStringXX managed property, since the hierarchy mechanism works off term IDs, not display text

If you've already built a multilingual taxonomy-backed refiner following that series, you likely have both pieces sitting there already, this build reused an existing mapping rather than creating anything new.

Building Real Hierarchy to Test Against

The demo term set here has a genuine two-level structure: a parent term, Data Protection, with three children underneath, Data Security, GDPR, and Identity, sitting alongside several flat, childless terms.

Term store showing a parent term with three child terms nested beneath it, alongside flat sibling terms

A handful of test pages were tagged deliberately across different depths, one tagged directly with the parent term, one with each child, and a couple with flat, non-hierarchical terms, specifically to prove three distinct behaviours: selecting the parent returns everything beneath it, selecting a single child returns only that, and a flat term behaves exactly like an ordinary refiner with no cascading at all.

Enabling the Template

On the Search Filters web part, once the taxonomy-backed refinable property is added, switching its Template dropdown to Hierarchical Filter surfaces a dedicated Hierarchical Settings panel alongside the row, with a few things worth setting deliberately rather than leaving on their defaults:

Setting Value Used Here Why
Term Set Demo taxonomy, set directly The specific term set this refiner should build its tree from, not inferred automatically from the managed property alone
Cache Duration 3 days How long the term hierarchy itself is cached before being re-fetched, the taxonomy structure changes rarely enough that refetching on every page load would be wasted overhead
Hide nodes not in the current data set Off Terms with zero matching results still appear in the tree, greyed out, rather than disappearing entirely, see below
Expand all nodes by default Off The tree opens collapsed and a visitor chooses which branch to explore, rather than being presented with everything expanded at once
The Hierarchical Settings panel showing Term Set, Cache Duration, and the two toggle options
The hierarchical refiner rendered on the page, showing an expandable tree with a parent term and its children

The multilingual half of this doesn't need anything extra at the filter level, it's the same Enable Localization setting on the connected Search Results web part's data source already covered in the multilingual series, and it applies to a hierarchical taxonomy refiner exactly the same way it applies to a flat one: every level of the tree switches language together, not just the top.

Note: a couple of top-level terms appear greyed out. That's the direct result of leaving Hide nodes not in the current data set switched off, the refiner correctly reflecting that no content in the current result set is tagged with those particular terms yet, without removing them from view entirely. It's a genuinely useful signal for the end user, rather than presenting every term in the taxonomy as equally clickable, or hiding ones with nothing behind them: it visually distinguishes what's actually browsable right now from what exists in the taxonomy but has no tagged content yet, useful context for anyone maintaining the taxonomy too.

Custom Styling Templates

This site's search pages use a custom Handlebars filter template rather than the default layout, built originally for the multilingual work, handling checkbox, combo box, date range, and date interval refiner types. Switching a filter to Hierarchical Refiner while using that custom template rendered nothing at all, no error, just an empty space where the tree should have been.

The reason turned out to be straightforward once found: a custom filter template has to explicitly handle every refiner type it might encounter, and Hierarchical Refiner is its own distinct type, unrelated to how the existing checkbox branch was built. Rather than guess at the right markup, the fastest reliable route was switching the web part back to its default layout temporarily, then inspecting the rendered page directly to see exactly what PnP Modern Search generates for this template type on its own.

That inspection turned up the actual custom element, pnp-filterhierarchical, and confirmed the value to check for, HierarchicalFilterTemplate, so a new branch could be added to the existing Handlebars template alongside the others, following the same data-filter and data-instance-id pattern already used throughout.

The Styling Gotcha

With the tree rendering, the checkboxes inside it as plain, unstyled browser defaults, no accent colour, nothing matching the rest of the site's refiners. The existing custom template already had styling rules for checkboxes, so the obvious assumption was that the new component simply wasn't inheriting them.

Digging into the rendered markup showed why: the hierarchical refiner's checkboxes use exactly the same underlying Fluent UI classes, ms-Checkbox, ms-Checkbox-checkbox, ms-ChoiceField-field, as the flat checkbox refiner already had rules for. The existing CSS just hadn't been written to reach them, since the original rules were scoped specifically underneath the pnp-filtercheckbox element, and this is a completely different custom element, pnp-filterhierarchical, even though it happens to render identical Fluent UI markup underneath.

The fix was adding the same colour rules a second time, scoped to the new element:

pnp-filterhierarchical .ms-Checkbox-checkbox,
pnp-filtercheckbox .ms-Checkbox-checkbox {
    accent-color: rgb(27, 114, 168) !important;
    border-color: rgb(27, 114, 168) !important;
}

The Result

A parent term now correctly returns results tagged with any of its children, a child term returns only its own tagged content, the tree expands and collapses cleanly, labels switch language along with the rest of the page, and the styling matches every other refiner on the site rather than standing out as an unstyled afterthought.

The mechanism itself, once you know where the pieces sit, isn't complicated. Most of the actual work here was in finding the right names, the component, the template identifier, the Fluent classes underneath, since none of it was obvious from configuration alone.

Want the prerequisites behind the taxonomy-to-refiner mapping used here? Start with the Scoped Multilingual Search with PnP Modern Search write-up, or go straight to the full build across the three technical parts below.