Navigation (Hierarchical) with Expand/Collapse
Navigation (Hierarchical) with Expand/Collapse
This pattern looks and acts like a tree, but it is NOT an ARIA tree (role="tree") and does NOT have the same keyboard behaviors as an ARIA tree. In this pattern, all nodes are navigable with the tab key (in ARIA trees, only one node is available to the tab key; the arrow keys are used for navigating within the tree). This pattern is simpler. It consists of nested lists with expand/collapse buttons to expose or hide child items, and standard HTML links one the final nodes of the branch.
Note:
Even though it is possible to create a navigation menu using an ARIA tree (role="tree"), ARIA trees are not yet fully supported across all screen readers. Because site navigation is so critical to basic website operation, using ARIA trees for navigation is NOT currently recommended.
Turn on a screen reader to experience this example in action.
HTML Source Code
<!---
This component has been adapted from an example provided by the W3C, in accordance with the W3C Software and Document License https://www.w3.org/copyright/software-license-2023/
-->
<div class="dqu-example">
<nav class="tree-nav" aria-label="Deque University">
<ul class="tree-root">
<li><a href="#home">Home</a></li>
<li>
<div id="expanderGroup" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="sect1" id="expander1id" >
<span class="expander-title"> School Activities <span class="expander-icon"></span>
</span>
</button>
<div id="sect1"
role="region"
aria-labelledby="expander1id"
class="expander-panel " hidden>
<ul>
<li>
<div id="expanderGroupPerf" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="perf-submenu" id="expanderPerf" >
<span class="expander-title"> Performance<span class="expander-icon"></span>
</span>
</button>
<div id="perf-submenu" role="region" aria-labelledby="expanderPerf" class="expander-panel " hidden>
<ul>
<li><a href="#band">Band</a></li>
<li><a href="#choir">Choir</a></li>
<li><a href="#theater">Theater</a></li>
</ul>
</div>
</div>
</li>
<li>
<div id="expanderGroupSports" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="sports-submenu" id="expanderSports" >
<span class="expander-title"> Sports<span class="expander-icon"></span>
</span>
</button>
<div id="sports-submenu" role="region" aria-labelledby="expanderSports" class="expander-panel " hidden>
<ul>
<li>
<div id="expanderGroupFall" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="fall-submenu" id="expanderFall" >
<span class="expander-title"> Fall<span class="expander-icon"></span>
</span>
</button>
<div id="fall-submenu" role="region" aria-labelledby="expanderFall" class="expander-panel " hidden>
<ul>
<li><a href="#football">Football</a></li>
<li><a href="#soccer">Soccer</a></li>
<li><a href="#crossCountry">Cross Country</a></li>
<li><a href="#cheerleading">Cheerleading</a></li>
</ul>
</div>
</div>
</li>
<li>
<div id="expanderGroupWinter" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="winter-submenu" id="expanderWinter" >
<span class="expander-title"> Winter<span class="expander-icon"></span>
</span>
</button>
<div id="winter-submenu" role="region" aria-labelledby="expanderWinter" class="expander-panel " hidden>
<ul>
<li><a href="#basketball">Basketball</a></li>
<li><a href="#wrestling">Wrestling</a></li>
<li><a href="#cheerleading">Cheerleading</a></li>
</ul>
</div>
</div>
</li>
<li>
<div id="expanderGroupSpring" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="spring-submenu" id="expanderSpring" >
<span class="expander-title"> Spring<span class="expander-icon"></span>
</span>
</button>
<div id="spring-submenu" role="region" aria-labelledby="expanderSpring" class="expander-panel " hidden>
<ul>
<li><a href="#baseball">Baseball</a></li>
<li><a href="#track">Track</a></li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</nav>
</div>
JavaScript Source Code
var tabExpanders = [];
class TabExpander {
static openedEles = new Map();
constructor(domNode, multiple ) {
if (typeof multiple == "undefined") multiple=true;
if (typeof multiple == "boolean") this.multiple = multiple;
this.rootEl = domNode;
this.buttonEl = this.rootEl.querySelector('button[aria-expanded]');
if ( this.buttonEl == null)
{
console.log("button and/or aria-expanded attribute is missing.");
return null;
}
const controlsId = this.buttonEl.getAttribute('aria-controls');
this.contentEl = document.getElementById(controlsId);
this.open = this.buttonEl.getAttribute('aria-expanded') === 'true';
this.closeActiveTab();
// add event listeners
this.buttonEl.addEventListener('click', this.onButtonClick.bind(this));
}
onButtonClick() {
this.toggle(!this.open);
}
toggle(open) {
// don't do anything if the open state doesn't change
if (open === this.open) {
return;
}
// update the internal state
this.open = open;
// handle DOM updates
this.buttonEl.setAttribute('aria-expanded', `${open}`);
if (open) {
this.contentEl.removeAttribute('hidden');
} else {
this.contentEl.setAttribute('hidden', '');
}
this.closeActiveTab();
}
closeActiveTab()
{
if (this.multiple ) return;
var group = this.rootEl.closest('.expander') || this.rootEl.parentElement;
if(this.open)
{
var prev = TabExpander.openedEles.get(group);
if( prev != null )
{
prev.contentEl.setAttribute('hidden', '');
prev.open = false;
prev.buttonEl.setAttribute('aria-expanded', 'false');
}
TabExpander.openedEles.set(group, this);
}
else
{
if(this == TabExpander.openedEles.get(group))
TabExpander.openedEles.delete(group);
}
}
closeOthers()
{
const expanders = document.querySelectorAll('.expander h2');
tabExpanders.forEach((expanderEl) => {
let tab = expanderEl;
if (tab.contentEl.id !== this.contentEl.id)
tab.close();
});
}
// Add public open and close methods for convenience
open() {
this.toggle(true);
}
close() {
this.toggle(false);
}
}
class TreeViewNavigation {
constructor(node, options) {
// Check whether node is a DOM element
if (typeof node !== 'object') {
return;
}
this.treeNode = node;
this.navNode = node.parentElement;
if (typeof options == "undefined") options = [];
if (!Array.isArray(options)) options = [];
if(typeof options["mode"] === "number")
{
var val = options["mode"];
if( val == MAX_HEIGHT_MODE)
{
if(typeof options["max_height"] === "number")
{
this.type = val;
this.maxHeight = options["max_height"];
this.setMaxHeightMode(this.maxHeight);
}
}
else
this.setAutoMode();
}
// init expanders
const expanders = this.treeNode.querySelectorAll('.expander');
expanders.forEach((expanderEl) => {
var tab = new TabExpander(expanderEl);
tabExpanders.push(tab);
});
}
setMaxHeightMode(maxHeight)
{
this.treeNode.style.maxHeight = maxHeight+"px";
this.treeNode.style.overflow = "auto";
}
setAutoMode()
{
this.treeNode.style.maxHeight = "";
this.treeNode.style.overflow = "";
}
}
const AUTO_HEIGHT_MODE = 1;
const MAX_HEIGHT_MODE = 2;
var treeViews = [];
window.addEventListener('load', function () {
var trees = document.querySelectorAll('.tree-root');
var options = [];
options["mode"] = MAX_HEIGHT_MODE;
options["max_height"] = 200;
for (let i = 0; i < trees.length; i++) {
//following line sets TreeviewNavigation in MAX_HEIGHT_MODE mode
treeViews.push(new TreeViewNavigation(trees[i], options));
}
});
//This component has been adapted from an example provided by the W3C, in accordance with the W3C Software and Document License https://www.w3.org/copyright/software-license-2023/
CSS Source Code
/*
Navigation with Expand/Collapse — Restyled
Deque University ARIA Component
This component has been adapted from an example provided by the W3C,
in accordance with the W3C Software and Document License
https://www.w3.org/copyright/software-license-2023/
*/
nav.tree-nav {
--dqu-bg-primary: #fcfaf8;
--dqu-bg-secondary: #f6f3ed;
--dqu-border-secondary: #8c827d;
--dqu-interactive: #2e5f7a;
--dqu-interactive-hover: #3a7a9a;
--dqu-interactive-light: rgba(46, 95, 122, 0.08);
--dqu-text-primary: #21201e;
--dqu-font-family: "Noto Sans", sans-serif;
--dqu-radius: 8px;
width: 300px;
font-family: var(--dqu-font-family);
background: var(--dqu-bg-secondary);
border: 1px solid var(--dqu-border-secondary);
border-radius: var(--dqu-radius);
overflow: hidden;
}
.expander {
margin: 0;
padding: 0;
}
.expander h2 {
margin: 0;
padding: 0;
}
.expander:focus-within {
/* No container border — highlight is on .expander-title instead */
}
.expander > * + * {
/* No divider lines between nested expander elements */
}
.expander-trigger {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
width: 100%;
margin: 0;
padding: 8px 12px;
text-align: left;
font-family: var(--dqu-font-family);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.5;
color: var(--dqu-interactive);
background: transparent;
border: 1px solid transparent;
border-radius: 0;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
/* Parents share one strong style for both hover and focus —
inverted colors (filled teal background, white text) give a
high-contrast indicator that meets WCAG 2.4.11. */
.expander-trigger:hover,
.expander-trigger:focus-visible {
background: var(--dqu-interactive);
color: #ffffff;
border-color: transparent;
outline: none !important;
z-index: 1;
}
/* Chevron flips to white when parent is hovered/focused */
.expander-trigger:hover .expander-icon,
.expander-trigger:focus-visible .expander-icon {
border-color: #ffffff;
}
.expander-trigger:focus:not(:focus-visible) {
outline: none !important;
}
.expander-trigger[aria-expanded="true"] {
border-bottom-color: var(--dqu-border-secondary);
}
/* Top-level (root) first / last items absorb the container's
interior corner radius (8px container - 1px border = 7px) so
focus/hover borders curve to match. Direct-child paths only,
so nested expander triggers and their separator lines stay
perfectly straight. */
.tree-root > li:first-child > a {
border-radius: 7px 7px 0 0;
}
.tree-root > li:last-child > a,
.tree-root > li:last-child > .expander > .expander-trigger[aria-expanded="false"] {
border-radius: 0 0 7px 7px;
}
.tree-nav button {
border-style: none;
}
.expander button::-moz-focus-inner {
border: 0;
}
.expander-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
pointer-events: none;
padding: 0;
outline: none;
}
.expander-icon {
border: solid var(--dqu-interactive);
border-width: 0 2px 2px 0;
height: 0.4rem;
width: 0.4rem;
pointer-events: none;
position: absolute;
right: 1em;
top: 50%;
transform: translateY(-60%) rotate(45deg);
transition: transform 0.2s ease;
}
.expander-trigger[aria-expanded="true"] .expander-icon {
transform: translateY(-50%) rotate(-135deg);
}
.expander-panel {
margin: 0;
padding: 2px 0 2px 12px;
line-height: 1.5;
}
.expander-panel[hidden] {
display: none;
}
.expander-panel .inactive {
display: none;
}
/* Navigation list styles */
.dqu-example ul {
list-style-type: none;
padding: 0;
margin: 0;
font-family: var(--dqu-font-family);
font-size: 0.9375rem;
}
.tree-root {
padding: 0;
}
.dqu-example li {
padding: 0;
}
.dqu-example li > a {
display: block;
padding: 6px 12px;
color: var(--dqu-interactive);
text-decoration: underline;
transition: background-color 0.15s ease;
}
/* Children share one subtle style for both hover and focus —
white background, 1px teal border, no underline. */
.dqu-example li > a:hover,
.dqu-example li > a:focus-visible {
background: #ffffff;
box-shadow: inset 0 0 0 1px var(--dqu-interactive);
text-decoration: none;
outline: none !important;
}