No notes defined.
{#
Structure: header > top row (logo, search form, search icon, menu button) + nav with nested lists.
An item with children is a <button> followed by its panel, which holds a back button and the child list.
idPrefix keeps the search form's and the nav's ids unique, so two headers can share a page.
The panels have no ids here: the JS pairs each button with the panel that follows it and adds the
ids, aria-controls and aria-expanded itself, so there is nothing to number in the markup.
Styles: _ds2-top-nav.scss. Behaviour: ds2-top-nav.module.js. Aiming at WCAG 2.2 AA.
#}
{% set idPrefix = idPrefix or 'ds2-top-nav' %}
{# only written when hover is turned off, so leaving the option out keeps hover on #}
<header class="ds2-top-nav"{% if desktopHover === false %} data-desktop-hover="false"{% endif %}>
<div class="ds2-top-nav__bar">
{# crest and wordmark are separate svgs so the wordmark can be hidden on smaller screens #}
{# the svgs are decorative and hidden, the link is named by the text below them (WCAG 1.1.1, 2.4.4) #}
<a class="ds2-top-nav__logo" href="{{ logo.url }}">
<svg class="ds2-top-nav__logo-crest" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261 261.2" aria-hidden="true" focusable="false">
<polygon points="142,168.4 146.6,168.4 146.6,126.8 156.8,126.8 156.8,119.2 142,119.2"></polygon>
<polygon points="162.4,168.4 167,168.4 167,126.8 177.2,126.8 177.2,119.2 162.4,119.2"></polygon>
<polygon points="169,83.4 173.2,83.4 173.2,79.6 166.4,79.6 166.4,91 169,91"></polygon>
<polygon points="157,83.4 161.2,83.4 161.2,79.6 154.4,79.6 154.4,91 157,91"></polygon>
<polygon points="182.8,168.4 187.4,168.4 187.4,126.8 197.6,126.8 197.6,119.2 182.8,119.2"></polygon>
<path d="M184.2,206.3c0-8.4-6.6-14.2-14.4-14.2s-14.4,5.8-14.4,14.2c0,8.4,6.6,14.2,14.4,14.2
S184.2,214.7,184.2,206.3z M160.8,206.3c0-5.4,4.2-9,9-9s9,3.6,9,9c0,5.4-4.2,9-9,9S160.8,211.7,160.8,206.3z"></path>
<polygon points="137.2,112.2 202.4,112.2 205.2,108 134.4,108"></polygon>
<path d="M0,0.1v261h114.2V192H142v69.1h4.6v-77.7H193v77.7h4.6V192h27.8v69.1H261V0.1H0z M169.6,30.1h0.4l26.8,16.4
h-54L169.6,30.1z M142.4,49.7h54.8l2.8,19.8h-60.4L142.4,49.7z M219.6,187.4h-22v-8.6H142v8.6h-22v-19.2h3.8v-62.8h6V72.8h80v32.6
h6v62.8h3.8V187.4z"></path>
<polygon points="181,83.4 185.2,83.4 185.2,79.6 178.4,79.6 178.4,91 181,91"></polygon>
</svg>
{# viewBox starts at the crest's right edge so the original gap is kept #}
<svg class="ds2-top-nav__logo-wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="261 0 1662 261.2" aria-hidden="true" focusable="false">
<path d="M1851.2,174c7.6,5,18.4,9.8,33.4,9.8c22,0,38.4-12.2,38.4-31.6c0-33-45.2-30.6-45.2-48.6
c0-6.2,6.2-9.6,13.8-9.6c8.8,0,16.2,3,25,7.8l0.4-19c-6-3.6-15.4-6.4-26.2-6.4c-23.2,0-35.6,14-35.6,30c0,8,2.4,14,6.4,18.6
c13,14.4,38.4,16.8,38.4,30.6c0,8-8.6,10.6-16.4,10.6c-9.4,0-17.4-3.8-24-8L1851.2,174z M1769.6,164.2V95.8h13.4
c21.4,0,34.6,13.2,34.6,34.2c0,20.6-13.2,34.2-34.6,34.2H1769.6z M1748,182.4h34.6c34,0,57.6-21.6,57.6-53
c0-30.8-21.6-51.6-57.2-51.6h-35V182.4z M1666.4,182.4h60.6v-18h-39V138h33.6v-18.2H1688V95.6h38.8V77.8h-60.4V182.4z
M1584.8,182.4h60.6v-18h-39V138h33.4v-18.2h-33.4V95.6h38.6V77.8h-60.2V182.4z M1509.4,182.4h58.4v-18h-36.6V77.8h-21.8V182.4z
M1401,182.4h21.8v-44h32.4v-18h-32.4V95.6h38V77.8H1401V182.4z M1292.6,130c0-21.6,14.2-35.8,33-35.8s33.2,14.2,33.2,35.8
c0,21.8-14.4,35.8-33.2,35.8S1292.6,151.8,1292.6,130 M1269.8,130c0,31,24,53.8,55.8,53.8c32,0,55.8-22.8,55.8-53.8
s-23.8-53.8-55.8-53.8C1293.8,76.2,1269.8,99,1269.8,130 M1142.8,77.8l36.4,63.2v41.4h21.8v-41.2l36-63.4h-22.6l-16,29.4
c-2.4,4.4-5.4,10-7.6,14.8h-0.2c-2.4-4.8-5.2-10.6-7.8-15l-16-29.2H1142.8z M1047.8,95.6h31.4v86.8h21.6V95.6h31.4V77.8h-84.4V95.6
z M1007.6,182.4h21.6V77.8h-21.6V182.4z M915.2,174c7.4,5,18.2,9.8,33.2,9.8c22.4,0,38.4-11.6,38.4-31.6c0-32.4-45.2-31-45.2-48.6
c0-6.2,6.2-9.6,14-9.6c8.6,0,16,3,25,7.8l0.2-19c-5.8-3.6-15.2-6.4-26.2-6.4c-23.2,0-35.6,14-35.6,30c0,8,2.4,14,6.6,18.6
c12.8,14.4,38.2,16.8,38.2,30.6c0,8-8.4,10.6-16.4,10.6c-9.4,0-17.2-3.8-24-8L915.2,174z M847.4,126V95.6h9.2
c10.4,0,16.2,6.2,16.2,14.4c0,9-7,16-16.2,16H847.4z M825.6,182.4h21.8V142h8.2c3.6,1.4,10,13.2,26,40.4h24.8
c-15.8-26.8-25.8-44.4-31.2-47.4v-0.2c9.6-4.4,19-13.4,19-27c0-16.2-10.2-30-38-30h-30.6V182.4z M744,182.4h60.8v-18h-39V138h33.4
v-18.2h-33.4V95.6h38.6V77.8H744V182.4z M624,77.8l43.4,105.4h17.4l44.6-105.4h-22.2l-22.8,55.8c-2.8,6.6-5,13.2-7.2,19.4h-0.4
c-2-6.2-4.4-12.6-7-19.4l-22-55.8H624z M587.6,182.4h21.6V77.8h-21.6V182.4z M465,182.4h21.4V125c0-4.8,0-11.2-0.4-14.6h0.4
c3,4.6,6,8.6,10,13.8l45.4,58.2H562V77.8h-21.4V131c0,4.4,0,11.4,0.2,16.4h-0.2c-3-4.4-5.8-8.6-10-13.8l-44-55.8H465V182.4z
M351.2,141.6c0,27.6,15.4,42.2,44.6,42.2c28,0,45.6-14.2,45.6-42.6V77.8h-21.8V141c0,16.2-8,24.6-23.4,24.6
c-14.6,0-23.2-8.8-23.2-24.6V77.8h-21.8V141.6z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">{{ logo.label }}</span>
</a>
{# the arrow and the box travel together, so on desktop they share one flex box rather than
sitting in separate grid columns. It's display: contents below the desktop breakpoint,
so the bar's own flex layout is unchanged there #}
<div class="ds2-top-nav__search-group">
{# closes the search box. Only shown while the box is open, so it's the one visible way out on a touch screen #}
{# the same arrow as the menu's back buttons, named for what it does rather than where it goes #}
<button class="ds2-top-nav__search-close" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">{{ search.closeLabel }}</span>
</button>
{# a real form in a search landmark, so it's announced as search and Enter submits (WCAG 1.3.1) #}
{# the input has a proper label, not just a placeholder, and the label starts with the visible word "Search" (3.3.2, 2.5.3) #}
<form class="ds2-top-nav__search" id="{{ idPrefix }}-search" role="search" action="{{ search.action }}" method="get">
<label class="ds2-top-nav__search-label ds2-top-nav__visually-hidden" for="{{ idPrefix }}-search-input">{{ search.label }}</label>
<input class="ds2-top-nav__search-input" id="{{ idPrefix }}-search-input" name="{{ search.inputName }}" type="search" placeholder="{{ search.placeholder }}">
{% for field in search.hiddenFields %}
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
{% endfor %}
<button class="ds2-top-nav__search-submit" type="submit">
<svg class="ds2-top-nav__search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M20.67,18.67H19.61l-.37-.36a8.66,8.66,0,1,0-.93.93l.36.37v1.06l6.66,6.65,2-2Zm-8,0a6,6,0,1,1,6-6A6,6,0,0,1,12.67,18.67Z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">{{ search.submitLabel }}</span>
</button>
</form>
</div>
{# shows the search box and focuses the input, on every screen size. It never opens the menu #}
<button class="ds2-top-nav__search-toggle" type="button" aria-controls="{{ idPrefix }}-search">
<svg class="ds2-top-nav__search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M20.67,18.67H19.61l-.37-.36a8.66,8.66,0,1,0-.93.93l.36.37v1.06l6.66,6.65,2-2Zm-8,0a6,6,0,1,1,6-6A6,6,0,0,1,12.67,18.67Z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">{{ search.openLabel }}</span>
</button>
{# comes before the nav in the source so focus order matches the visual order on mobile and tablet (WCAG 2.4.3) #}
{# the old component put the burger after the nav, so tabbing from it left the header entirely #}
{# aria-expanded on this and the other disclosure buttons is added by the JS, so without JS nothing claims to be collapsed while its links are showing #}
<button class="ds2-top-nav__menu-toggle" type="button" aria-controls="{{ idPrefix }}-menu">
{# staggered lines taken from the mobile design reference, currentColor keeps it visible in forced colours #}
<svg class="ds2-top-nav__menu-icon ds2-top-nav__menu-icon--burger" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 12" aria-hidden="true" focusable="false">
<rect x="0" y="0" width="14" height="2"></rect>
<rect x="9" y="5" width="15" height="2"></rect>
<rect x="1" y="10" width="16" height="2"></rect>
</svg>
{# shown instead of the burger while the menu is open #}
<svg class="ds2-top-nav__menu-icon ds2-top-nav__menu-icon--close" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">{{ menuLabel }}</span>
</button>
</div>
{# a named navigation landmark, with the levels as nested lists so their structure is announced (WCAG 1.3.1) #}
<nav class="ds2-top-nav__nav" id="{{ idPrefix }}-menu" aria-label="{{ navLabel }}">
<ul class="ds2-top-nav__list ds2-top-nav__list--level-1">
{% for level1Option in level1Options %}
{% if level1Option.level2Options %}
{# items with children are buttons, not links: they open a panel rather than going anywhere (WCAG 4.1.2, 2.1.1) #}
{# the arrow is decorative, so it's hidden and the button is named by its text alone (1.1.1) #}
<li class="ds2-top-nav__item ds2-top-nav__item--level-1 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">{{ level1Option.title }}</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
{# the panel is the button's next sibling, and that's how the JS finds it. It gives the
panel an id and the button an aria-controls on init, along with aria-expanded, so
there are no ids to number here. Don't wrap either of them in anything #}
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-2">
{# the back button shows only an arrow, so hidden text says where it goes (WCAG 1.1.1, 2.4.6) #}
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to main menu</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-2">
{% for level2Option in level1Option.level2Options %}
{% if level2Option.level3Options %}
<li class="ds2-top-nav__item ds2-top-nav__item--level-2 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">{{ level2Option.title }}</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-3">
{# named after its destination, e.g. "Back to Study", so it makes sense out of context (WCAG 2.4.6) #}
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to {{ level1Option.title }}</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-3">
{% for level3Option in level2Option.level3Options %}
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="{{ level3Option.url }}">{{ level3Option.title }}</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% else %}
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="{{ level2Option.url }}">{{ level2Option.title }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</li>
{% else %}
<li class="ds2-top-nav__item ds2-top-nav__item--level-1">
<a class="ds2-top-nav__link" href="{{ level1Option.url }}">{{ level1Option.title }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
</header>
<header class="ds2-top-nav">
<div class="ds2-top-nav__bar">
<a class="ds2-top-nav__logo" href="https://www.leeds.ac.uk/">
<svg class="ds2-top-nav__logo-crest" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261 261.2" aria-hidden="true" focusable="false">
<polygon points="142,168.4 146.6,168.4 146.6,126.8 156.8,126.8 156.8,119.2 142,119.2"></polygon>
<polygon points="162.4,168.4 167,168.4 167,126.8 177.2,126.8 177.2,119.2 162.4,119.2"></polygon>
<polygon points="169,83.4 173.2,83.4 173.2,79.6 166.4,79.6 166.4,91 169,91"></polygon>
<polygon points="157,83.4 161.2,83.4 161.2,79.6 154.4,79.6 154.4,91 157,91"></polygon>
<polygon points="182.8,168.4 187.4,168.4 187.4,126.8 197.6,126.8 197.6,119.2 182.8,119.2"></polygon>
<path d="M184.2,206.3c0-8.4-6.6-14.2-14.4-14.2s-14.4,5.8-14.4,14.2c0,8.4,6.6,14.2,14.4,14.2
S184.2,214.7,184.2,206.3z M160.8,206.3c0-5.4,4.2-9,9-9s9,3.6,9,9c0,5.4-4.2,9-9,9S160.8,211.7,160.8,206.3z"></path>
<polygon points="137.2,112.2 202.4,112.2 205.2,108 134.4,108"></polygon>
<path d="M0,0.1v261h114.2V192H142v69.1h4.6v-77.7H193v77.7h4.6V192h27.8v69.1H261V0.1H0z M169.6,30.1h0.4l26.8,16.4
h-54L169.6,30.1z M142.4,49.7h54.8l2.8,19.8h-60.4L142.4,49.7z M219.6,187.4h-22v-8.6H142v8.6h-22v-19.2h3.8v-62.8h6V72.8h80v32.6
h6v62.8h3.8V187.4z"></path>
<polygon points="181,83.4 185.2,83.4 185.2,79.6 178.4,79.6 178.4,91 181,91"></polygon>
</svg>
<svg class="ds2-top-nav__logo-wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="261 0 1662 261.2" aria-hidden="true" focusable="false">
<path d="M1851.2,174c7.6,5,18.4,9.8,33.4,9.8c22,0,38.4-12.2,38.4-31.6c0-33-45.2-30.6-45.2-48.6
c0-6.2,6.2-9.6,13.8-9.6c8.8,0,16.2,3,25,7.8l0.4-19c-6-3.6-15.4-6.4-26.2-6.4c-23.2,0-35.6,14-35.6,30c0,8,2.4,14,6.4,18.6
c13,14.4,38.4,16.8,38.4,30.6c0,8-8.6,10.6-16.4,10.6c-9.4,0-17.4-3.8-24-8L1851.2,174z M1769.6,164.2V95.8h13.4
c21.4,0,34.6,13.2,34.6,34.2c0,20.6-13.2,34.2-34.6,34.2H1769.6z M1748,182.4h34.6c34,0,57.6-21.6,57.6-53
c0-30.8-21.6-51.6-57.2-51.6h-35V182.4z M1666.4,182.4h60.6v-18h-39V138h33.6v-18.2H1688V95.6h38.8V77.8h-60.4V182.4z
M1584.8,182.4h60.6v-18h-39V138h33.4v-18.2h-33.4V95.6h38.6V77.8h-60.2V182.4z M1509.4,182.4h58.4v-18h-36.6V77.8h-21.8V182.4z
M1401,182.4h21.8v-44h32.4v-18h-32.4V95.6h38V77.8H1401V182.4z M1292.6,130c0-21.6,14.2-35.8,33-35.8s33.2,14.2,33.2,35.8
c0,21.8-14.4,35.8-33.2,35.8S1292.6,151.8,1292.6,130 M1269.8,130c0,31,24,53.8,55.8,53.8c32,0,55.8-22.8,55.8-53.8
s-23.8-53.8-55.8-53.8C1293.8,76.2,1269.8,99,1269.8,130 M1142.8,77.8l36.4,63.2v41.4h21.8v-41.2l36-63.4h-22.6l-16,29.4
c-2.4,4.4-5.4,10-7.6,14.8h-0.2c-2.4-4.8-5.2-10.6-7.8-15l-16-29.2H1142.8z M1047.8,95.6h31.4v86.8h21.6V95.6h31.4V77.8h-84.4V95.6
z M1007.6,182.4h21.6V77.8h-21.6V182.4z M915.2,174c7.4,5,18.2,9.8,33.2,9.8c22.4,0,38.4-11.6,38.4-31.6c0-32.4-45.2-31-45.2-48.6
c0-6.2,6.2-9.6,14-9.6c8.6,0,16,3,25,7.8l0.2-19c-5.8-3.6-15.2-6.4-26.2-6.4c-23.2,0-35.6,14-35.6,30c0,8,2.4,14,6.6,18.6
c12.8,14.4,38.2,16.8,38.2,30.6c0,8-8.4,10.6-16.4,10.6c-9.4,0-17.2-3.8-24-8L915.2,174z M847.4,126V95.6h9.2
c10.4,0,16.2,6.2,16.2,14.4c0,9-7,16-16.2,16H847.4z M825.6,182.4h21.8V142h8.2c3.6,1.4,10,13.2,26,40.4h24.8
c-15.8-26.8-25.8-44.4-31.2-47.4v-0.2c9.6-4.4,19-13.4,19-27c0-16.2-10.2-30-38-30h-30.6V182.4z M744,182.4h60.8v-18h-39V138h33.4
v-18.2h-33.4V95.6h38.6V77.8H744V182.4z M624,77.8l43.4,105.4h17.4l44.6-105.4h-22.2l-22.8,55.8c-2.8,6.6-5,13.2-7.2,19.4h-0.4
c-2-6.2-4.4-12.6-7-19.4l-22-55.8H624z M587.6,182.4h21.6V77.8h-21.6V182.4z M465,182.4h21.4V125c0-4.8,0-11.2-0.4-14.6h0.4
c3,4.6,6,8.6,10,13.8l45.4,58.2H562V77.8h-21.4V131c0,4.4,0,11.4,0.2,16.4h-0.2c-3-4.4-5.8-8.6-10-13.8l-44-55.8H465V182.4z
M351.2,141.6c0,27.6,15.4,42.2,44.6,42.2c28,0,45.6-14.2,45.6-42.6V77.8h-21.8V141c0,16.2-8,24.6-23.4,24.6
c-14.6,0-23.2-8.8-23.2-24.6V77.8h-21.8V141.6z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">University of Leeds homepage</span>
</a>
<div class="ds2-top-nav__search-group">
<button class="ds2-top-nav__search-close" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Close search</span>
</button>
<form class="ds2-top-nav__search" id="ds2-top-nav-search" role="search" action="https://www.leeds.ac.uk/search" method="get">
<label class="ds2-top-nav__search-label ds2-top-nav__visually-hidden" for="ds2-top-nav-search-input">Search leeds.ac.uk</label>
<input class="ds2-top-nav__search-input" id="ds2-top-nav-search-input" name="q" type="search" placeholder="Search">
<input type="hidden" name="searchOption" value="searchSite">
<button class="ds2-top-nav__search-submit" type="submit">
<svg class="ds2-top-nav__search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M20.67,18.67H19.61l-.37-.36a8.66,8.66,0,1,0-.93.93l.36.37v1.06l6.66,6.65,2-2Zm-8,0a6,6,0,1,1,6-6A6,6,0,0,1,12.67,18.67Z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">Search</span>
</button>
</form>
</div>
<button class="ds2-top-nav__search-toggle" type="button" aria-controls="ds2-top-nav-search">
<svg class="ds2-top-nav__search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M20.67,18.67H19.61l-.37-.36a8.66,8.66,0,1,0-.93.93l.36.37v1.06l6.66,6.65,2-2Zm-8,0a6,6,0,1,1,6-6A6,6,0,0,1,12.67,18.67Z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">Search</span>
</button>
<button class="ds2-top-nav__menu-toggle" type="button" aria-controls="ds2-top-nav-menu">
<svg class="ds2-top-nav__menu-icon ds2-top-nav__menu-icon--burger" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 12" aria-hidden="true" focusable="false">
<rect x="0" y="0" width="14" height="2"></rect>
<rect x="9" y="5" width="15" height="2"></rect>
<rect x="1" y="10" width="16" height="2"></rect>
</svg>
<svg class="ds2-top-nav__menu-icon ds2-top-nav__menu-icon--close" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path>
</svg>
<span class="ds2-top-nav__visually-hidden">Menu</span>
</button>
</div>
<nav class="ds2-top-nav__nav" id="ds2-top-nav-menu" aria-label="Main">
<ul class="ds2-top-nav__list ds2-top-nav__list--level-1">
<li class="ds2-top-nav__item ds2-top-nav__item--level-1 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Study</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-2">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to main menu</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-2">
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://courses.leeds.ac.uk/">Course search</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Undergraduate</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-3">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to Study</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-3">
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/undergraduate">Undergraduate home page</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/clearing">Clearing 2026</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://courses.leeds.ac.uk/course-search/undergraduate-courses">Undergraduate course search</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/undergraduate-fees/doc/fees-undergraduate-fees">Fees and funding</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/undergraduate-student-life/doc/students-union">Student life</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Online courses</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-3">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to Study</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-3">
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/online-courses">Overview</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/online-masters-study">Online Masters study</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/short-online-courses">Short online courses</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/online-courses/doc/phd-distance-learning">PhD by distance learning</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Masters</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-3">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to Study</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-3">
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/masters">Masters home page</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/postgraduate-events">Postgraduate open day</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://courses.leeds.ac.uk/course-search/masters-courses?type=PGT&query=">Masters course search</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/masters-applying/doc/apply-masters-courses">Applying</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-3">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/masters-offer/doc/masters-fees-funding">Fees and funding</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/research-degrees">Research degrees</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/international-students">International students</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/foundation-courses">Foundation courses</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/mature-students">Mature students</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-1 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Faculties</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-2">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to main menu</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-2">
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://ahc.leeds.ac.uk/">Arts, Humanities and Cultures</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://biologicalsciences.leeds.ac.uk/">Biological Sciences</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://business.leeds.ac.uk/">Business School</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://eps.leeds.ac.uk/">Engineering and Physical Sciences</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://environment.leeds.ac.uk/">Environment</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://medicinehealth.leeds.ac.uk/">Medicine and Health</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://essl.leeds.ac.uk/">Social Sciences</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/about/doc/faculties-contacts">Faculty contacts</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-1 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Research</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-2">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to main menu</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-2">
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/research-and-innovation">Research overview</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/research-impact">Research excellence and impact</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/research-strategy-environment">Strategy and environment</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/researcher-staff-development">Researcher and staff development</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/research-public-engagement">Public engagement</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/research-and-innovation/doc/research-innovation-contacts">Research and innovation contacts</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-1 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">Business</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-2">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to main menu</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-2">
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/business-partnerships">Overview</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/business-partnerships-access-expertise/doc/work-with-academics">Access our expertise</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/training-development/doc/cpd-professional-learning-businesses-employers">Training and development</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/business-partnerships/doc/recruit-students">Recruit our students and graduates</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/business-partnerships/doc/use-facilities">Use our facilities</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/business-partnerships/doc/support-small-businesses">Support for small businesses</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/business-partnerships/doc/business-partners">Our business partners</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-1 ds2-top-nav__item--has-children">
<button class="ds2-top-nav__link ds2-top-nav__link--button" type="button">
<span class="ds2-top-nav__link-text">About</span>
<span class="ds2-top-nav__arrow" aria-hidden="true"></span>
</button>
<div class="ds2-top-nav__panel ds2-top-nav__panel--level-2">
<button class="ds2-top-nav__back" type="button">
<span class="ds2-top-nav__back-arrow" aria-hidden="true"></span>
<span class="ds2-top-nav__visually-hidden">Back to main menu</span>
</button>
<ul class="ds2-top-nav__list ds2-top-nav__list--level-2">
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/campusmap">Campus map</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/about/doc/about-history">Our history</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/our-faculties">Faculties</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/structure-governance/doc/governance">Governance</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/structure-governance/doc/reports-accounts-policies">Reports, accounts and policies</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/about/doc/strategies-values">Strategies and values</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://equality.leeds.ac.uk/">Equality</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/university-executive">Executive team</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/university-news">News</a>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-2">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/events/">Events</a>
</li>
</ul>
</div>
</li>
<li class="ds2-top-nav__item ds2-top-nav__item--level-1">
<a class="ds2-top-nav__link" href="https://www.leeds.ac.uk/alumni">Alumni</a>
</li>
</ul>
</nav>
</header>
// Mobile first: the base is the mobile and tablet layout, then the JS-only states
// under &--js, then one desktop media query at the bottom.
// State and behaviour are in ds2-top-nav.module.js. Aiming at WCAG 2.2 AA.
.ds2-top-nav {
// replace with DS2 colour names when added
$bgColour: #000;
$fgColour: #fff;
$highlightColour: #D9FF2E;
$searchBorderColour: #999;
// must match desktopMediaQuery in ds2-top-nav.module.js
// in rem so it follows the reader's text size: 80rem is 1280px at the usual 16px text,
// or 1600px if they've set 20px, because bigger text needs a wider screen before the row fits.
// With a px breakpoint, bigger text made the desktop nav overflow between 1280 and about 1500px.
// (in a media query rem goes by the browser's own setting, not by font-size on html)
$desktopBreakpoint: 80rem;
// spacing uses the global $spacing-* scale (see /docs/spacing) wherever a value lands on it.
// what's still in px is either a size rather than spacing, a Figma measurement that isn't on the
// scale (5px, 15px, 36px, 50px), or spacing that feeds Sass arithmetic alongside a px size:
// $barHeight and $searchRowOthers add padding to the crest and the icon buttons, and Sass can't
// add rem to px. Converting those means moving the sums into calc()
//
// **To confirm against a clean Figma export:** $linkPaddingInline (36px) is 4px off $spacing-6
// and was left alone because it sets the text inset on every row and the child rows line up
// against it. $rowHeight, $searchHeight and the icon sizes are near scale steps too, but they're
// sizes rather than spacing, so they stay px whatever the number
//
// mobile values come from the Figma file, which uses a 390px wide frame
// values marked (image) were measured from the mobile reference image scaled to 390px, refine them from cleaner exports
// top row, its height comes from the padding and the logo rather than being fixed
$barPaddingTop: 32px;
$barPaddingBottom: 24px;
$barPaddingInline: 24px;
$logoPadding: 24px;
$crestWidth: 43.49px;
$crestHeight: 42.74px;
// only used to size the open menu below the top row
$barHeight: $barPaddingTop + $logoPadding * 2 + $crestHeight + $barPaddingBottom;
// icon buttons, the menu button is wider so the burger sits 12px in from its edge like the Figma button group
$iconButtonSize: 40px;
$menuButtonSize: 48px;
// the icon paths have space round them, these sizes give the Figma glyph sizes (search about 15px, close about 15px)
$searchIconSize: 20px;
$closeIconSize: 26px;
$burgerWidth: 24px;
$burgerHeight: 12px;
// search box in the open menu, flexible width
$searchHeight: 60px;
$searchGapInline: 12px;
$searchRadius: 4px;
$searchSubmitInset: $spacing-2;
// was 5px, snapped to the scale: a 1px difference measured off a scaled screenshot
$searchInputPaddingStart: $spacing-1;
$searchInputPaddingEnd: $spacing-3;
// menu list: 60px rows with 4px between them, 50px below the top row
// text in rem so it follows the browser's font size as well as page zoom (WCAG 1.4.4)
// line height has no unit so it grows with the text
$linkFontSize: 1rem;
$linkLineHeight: 1.5;
$rowHeight: 60px;
$rowGap: $spacing-1;
$linkPaddingInline: 36px; // (image)
// was 50px, and Figma said "about 50", so the 2px to the nearest step is inside that
$listPaddingTop: $spacing-7;
$listPaddingBottom: $spacing-6;
// drill-down panels (image), children line up with the level 1 text as the parent row they were indented under isn't built
$panelPaddingTop: $spacing-5;
$childRowHeight: 56px;
$backHeight: 60px;
$backPaddingInline: $spacing-5;
$slideDuration: 0.3s;
// tablet (481-1279) uses the mobile layout, these stop the search box and menu rows stretching across wide screens
// both are wider than anything reached on mobile, so they need no breakpoint
$searchMaxWidth: 480px;
$listMaxWidth: 480px;
// when the menu opens the search box slides out leftwards from where the search icon was, like the old component
// it animates max-width, as a flexible width can't be transitioned; the end value is the box's real width,
// the screen minus everything else in the open top row, so the slide takes the full time at every screen size
$searchSlideDuration: 0.4s;
// what shares the row with the open box: the bar's padding, the close arrow, the box's own margins and the burger.
// the logo is hidden while the box is open, so it isn't counted
$searchRowOthers: $barPaddingInline * 2 + $iconButtonSize + $searchGapInline * 2 + $menuButtonSize;
// the open box's cap. It only binds from 690px up (530 + $searchRowOthers); below that the row
// is the limit, so a phone is unaffected by this number and there's no tablet media query.
// 305px was the value from the old design, but it only ever bound from 465px up, which is tablet
$searchMaxWidthOpen: 530px;
$searchSlideEndWidth: unquote("min(#{$searchMaxWidthOpen}, calc(100vw - #{$searchRowOthers}))");
// arrow drawn with borders so it stays visible in forced colours, 13 x 11 in Figma
$arrowWidth: 13px;
// was 15px measured from the image, so 1px off the scale is inside the measurement error
$arrowGap: $spacing-4;
$arrowStroke: 2px;
$arrowHeadSize: 8px;
// desktop (1280 and up), values from the old ds2-top-nav desktop SCSS and screenshots
$desktopBarHeight: 80px;
$desktopMaxWidth: 1400px;
$desktopPaddingInline: $spacing-4;
$desktopLogoHeight: 34px;
$desktopLinkPaddingInline: $spacing-4;
// narrows towards 1280 (16px at 1440, 12px at 1280) so the nav still fits beside the open
// search box when a user applies their own text spacing (WCAG 1.4.12), or when labels are longer
$desktopLinkPaddingFluid: unquote("clamp(10px, calc(2.5vw - 20px), #{$desktopLinkPaddingInline})");
// between the logo, nav and search; also the smallest gap left between the logo and the nav when search is open
$desktopColumnGap: $spacing-4;
$desktopRowHeight: 52px;
$desktopPanelPaddingTop: $spacing-3;
$desktopPanelPaddingBottom: $spacing-7;
$desktopLevel2Width: 33%;
$desktopLevel2ArrowWidth: 20px;
$desktopArrowToLevel3Gap: $spacing-5;
$desktopSearchWidth: 250px;
// the open box fills the space the links leave, up to this
$desktopSearchMaxWidth: 800px;
$desktopSearchHeight: 44px;
$arrowGrowDuration: 0.4s;
$arrowTurnDuration: 0.2s;
$focusOutlineWidth: 2px;
$focusOutlineOffset: 2px;
$menuZIndex: 100;
$seamOverlap: 1px;
$noJsIndent: 24px;
position: relative;
font-family: uolInter, Arial, Helvetica, sans-serif;
background: $bgColour;
color: $fgColour;
// the site doesn't set border-box globally, so full width rows would be their padding wider than the screen
&,
*,
*::before,
*::after {
box-sizing: border-box;
}
// one focus style for the whole header, keyboard focus only (WCAG 2.4.7)
// the ring is the highlight colour, about 18:1 against the black bar (WCAG 1.4.11 wants 3:1)
// the search input is left out: its ring goes round the whole search box, see &__search
// :where keeps the specificity low, so full width rows can move the ring inside themselves
:where(a, button):focus-visible {
outline: $focusOutlineWidth solid $highlightColour;
outline-offset: $focusOutlineOffset;
}
// top row
&__bar {
display: flex;
align-items: center;
// the controls that fade out while the search box opens are positioned against this
position: relative;
// the logo is the tallest thing in the row, so the bar would shrink whenever it's hidden
// (opening the search box). This holds the height the logo would have given it.
// $barHeight, not the logo's own height, because box-sizing is border-box here so this
// has to include the bar's padding
min-height: $barHeight;
padding: $barPaddingTop $barPaddingInline $barPaddingBottom;
}
&__logo {
display: inline-flex;
align-items: center;
margin-right: auto;
padding: $logoPadding;
color: inherit;
fill: currentColor;
}
&__logo-crest {
width: $crestWidth;
height: $crestHeight;
}
// wordmark only has room on desktop, the crest alone is used below that
&__logo-wordmark {
display: none;
height: $crestHeight;
@media (min-width: $desktopBreakpoint) {
display: block;
}
}
// shared reset for the icon buttons
&__search-toggle,
&__search-close,
&__menu-toggle,
&__search-submit {
align-items: center;
justify-content: center;
flex: none;
width: $iconButtonSize;
height: $iconButtonSize;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
// hover colour for mouse users only, so it doesn't stick after a tap on a touch screen
@media (hover: hover) {
&:hover {
color: $highlightColour;
}
}
}
&__menu-toggle {
width: $menuButtonSize;
height: $menuButtonSize;
}
// only shown once the JS is running, see &--js below
&__search-toggle,
&__menu-toggle {
display: none;
}
// shown only while the search box is open, see &--search-open below
// it's the one visible way to close the box, which matters on a touch screen where there's no Escape
&__search-close {
display: none;
.ds2-top-nav__back-arrow {
// the arrow is the whole button here, rather than sitting after a label
margin: 0;
}
}
&__search-icon {
width: $searchIconSize;
height: $searchIconSize;
fill: currentColor;
}
&__menu-icon {
fill: currentColor;
&--burger {
width: $burgerWidth;
height: $burgerHeight;
}
&--close {
display: none;
width: $closeIconSize;
height: $closeIconSize;
}
}
// icon follows the button state rather than a class, so it can't disagree with what is announced
&__menu-toggle[aria-expanded="true"] {
.ds2-top-nav__menu-icon--burger {
display: none;
}
.ds2-top-nav__menu-icon--close {
display: block;
}
}
// the arrow and the box are one unit on desktop, so they slide together. Everywhere else this
// wrapper gets out of the way and they behave as direct children of the bar
&__search-group {
display: contents;
}
// search box, shown in the open menu (or always when there is no JS)
&__search {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
max-width: $searchMaxWidth;
height: $searchHeight;
margin-inline: $searchGapInline;
border: 1px solid $searchBorderColour;
border-radius: $searchRadius;
// the input's focus ring goes round the whole box instead of inside it.
// :has() is the newest thing this component uses: Firefox only got it in December 2023.
// if it ever isn't supported the ring goes round the input instead, which is only cosmetic
&:has(.ds2-top-nav__search-input:focus-visible) {
outline: $focusOutlineWidth solid $highlightColour;
outline-offset: $focusOutlineOffset;
}
}
&__search-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 $searchInputPaddingEnd 0 $searchInputPaddingStart;
border: 0;
background: transparent;
color: inherit;
font: inherit;
// 16px or more stops iOS zooming in when the input is focused, 1rem is 16px unless the user has chosen larger
font-size: $linkFontSize;
&::placeholder {
color: inherit;
opacity: 1;
}
&:focus {
outline: none;
}
}
// submit comes after the input in the source so it's announced in the usual order, but sits on the left as in the design
&__search-submit {
display: inline-flex;
order: -1;
margin-left: $searchSubmitInset;
}
// menu
&__list {
display: flex;
flex-direction: column;
gap: $rowGap;
max-width: $listMaxWidth;
margin: 0;
padding: 0;
list-style: none;
}
&__list--level-1 {
padding-block: $listPaddingTop $listPaddingBottom;
}
&__link {
display: flex;
align-items: center;
width: 100%;
// min-height, not height, so rows grow rather than clip if the text needs more room
// and they're well over the 24px minimum tap target (WCAG 2.5.8)
min-height: $rowHeight;
padding: 0 $linkPaddingInline;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: $linkFontSize;
line-height: $linkLineHeight;
text-align: left;
text-decoration: none;
cursor: pointer;
// rows are full width, so the focus ring sits inside them rather than being cut off
// at the screen edge, where part of it would be invisible (WCAG 2.4.7)
&:focus-visible {
outline-offset: -$focusOutlineWidth * 2;
}
@media (hover: hover) {
&:hover {
color: $highlightColour;
}
}
}
// rows inside panels are a little shorter than level 1 rows
&__panel &__link {
min-height: $childRowHeight;
}
&__list--level-2,
&__list--level-3 {
padding-bottom: $listPaddingBottom;
}
// back buttons need the JS, they're shown in &--js below
&__back {
display: none;
align-items: center;
height: $backHeight;
padding: 0 $backPaddingInline;
border: 0;
background: none;
color: $highlightColour;
cursor: pointer;
// sits against the screen edge, so the focus ring goes inside it
&:focus-visible {
outline-offset: -$focusOutlineWidth * 2;
}
}
// arrows are drawn with borders in currentColor, not backgrounds: Windows high contrast
// removes backgrounds but keeps borders, which is what left the old component with a
// stray chevron and no line (supports WCAG 1.4.11)
&__arrow,
&__back-arrow {
position: relative;
flex: none;
width: $arrowWidth;
border-top: $arrowStroke solid currentColor;
// head is a square with two borders turned 45deg, its corner lands on the end of the line
&::after {
content: '';
position: absolute;
top: -($arrowStroke / 2) - ($arrowHeadSize / 2);
right: $arrowStroke / 2;
width: $arrowHeadSize;
height: $arrowHeadSize;
border-top: $arrowStroke solid currentColor;
border-right: $arrowStroke solid currentColor;
transform: rotate(45deg);
}
}
&__arrow {
margin-left: $arrowGap;
}
// same arrow, pointing left
&__back-arrow {
transform: scaleX(-1);
}
// without JS every level is shown as one long list: the arrows would suggest a drill-down that can't happen,
// so they're hidden, and child lists are indented so the structure is still clear
&:not(.ds2-top-nav--js) {
.ds2-top-nav__arrow {
display: none;
}
.ds2-top-nav__list--level-2,
.ds2-top-nav__list--level-3 {
padding-left: $noJsIndent;
}
}
// with JS the menu starts closed: search and nav are hidden behind the icon buttons
// without JS they stay visible so the links and search still work
&--js {
.ds2-top-nav__search-toggle,
.ds2-top-nav__menu-toggle {
display: inline-flex;
}
.ds2-top-nav__search,
.ds2-top-nav__nav {
display: none;
}
// menu covers the page below the top row, at least to the bottom of the screen
// it starts 1px up, over the top row's empty bottom padding, so no line of the page shows through where the two meet
// on scaled displays (125%, 150%), padding makes up the 1px so the content doesn't move
.ds2-top-nav__nav {
position: absolute;
top: calc(100% - #{$seamOverlap});
left: 0;
right: 0;
z-index: $menuZIndex;
min-height: calc(100vh - #{$barHeight - $seamOverlap});
min-height: calc(100dvh - #{$barHeight - $seamOverlap});
padding-top: $seamOverlap;
background: $bgColour;
// clip rather than hidden, so off-screen panels can't cause sideways scrolling
// and the page can still scroll down to reach a long menu, including at 400% zoom (WCAG 1.4.10).
// Safari only supports clip from 16. Falling back to visible would let the closed panels
// off to the right cause sideways scrolling
overflow-x: clip;
}
.ds2-top-nav__back {
display: inline-flex;
}
// drill-down: each panel slides in from the right over the list it belongs to
// level 2 panels are positioned against the nav, level 3 panels against their level 2 panel
.ds2-top-nav__panel {
position: absolute;
// arrows are positioned too, without this the arrows of later items in the list paint on top of the panel
z-index: 1;
top: 0;
left: 0;
width: 100%;
// closed panels are cut to the menu's height so their content doesn't make the page scroll further
height: 100%;
overflow: hidden;
padding-top: $panelPaddingTop;
background: $bgColour;
transform: translateX(100%);
// hidden once it has slid out, so nothing in it can be focused or read out
// (visibility, not just moving it off screen, which would leave it reachable) (WCAG 2.4.11, 4.1.2)
visibility: hidden;
transition: transform $slideDuration ease, visibility 0s linear $slideDuration;
}
.ds2-top-nav__panel--open {
height: auto;
min-height: 100%;
overflow: visible;
transform: none;
visibility: visible;
transition: transform $slideDuration ease;
}
// panels appear without sliding for anyone who has asked for less motion
// (WCAG 2.3.3 is AAA, but this costs nothing and helps with vestibular disorders)
@media (prefers-reduced-motion: reduce) {
.ds2-top-nav__panel,
.ds2-top-nav__panel--open {
transition: none;
}
}
}
// open menu, mobile and tablet
// the open box spans the bar: the close arrow sits where the logo was, the burger stays at the right
&--menu-open {
.ds2-top-nav__nav {
display: block;
}
}
// the search box belongs to the search icon, not the menu: the burger opens the menu with the icon still showing
// --search-closing holds this layout for the length of the animation on the way out, because a
// closed box is display: none and so has nothing to animate from. The JS takes the class off at the end
&--search-open,
&--search-closing {
// the arrow belongs to the box: it sits against its left edge and moves with it as it grows,
// rather than floating alone at the edge of the bar. This auto margin and the box's one
// split the free space, so the pair is centred and the burger keeps the right-hand end
.ds2-top-nav__search-close {
display: inline-flex;
margin-left: auto;
}
.ds2-top-nav__search {
display: flex;
max-width: $searchSlideEndWidth;
// the gap to the arrow stays fixed, the space to the burger is free, so the arrow and box
// stay together and the pair grows outwards from the middle
margin-inline: $searchGapInline auto;
// the input and icon stay inside the box while it's narrow
overflow: hidden;
}
}
// settled open: the logo and the icon are gone for good
// display: none, not visibility, so they leave the tab order with it
&--search-open {
.ds2-top-nav__logo,
.ds2-top-nav__search-toggle {
display: none;
}
.ds2-top-nav__search {
animation: ds2-top-nav-search-grow $searchSlideDuration ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
// while the box is opening or closing, the logo and the search icon cross-fade with the arrow.
// position: absolute with no offsets leaves them exactly where they sit but takes them out of the
// flow, so the box has its room straight away and nothing jumps as they go.
// these rules come after &--search-open so they win where both classes are on the header
&--search-opening,
&--search-closing {
.ds2-top-nav__logo,
.ds2-top-nav__search-toggle {
display: inline-flex;
// each one needs its own offsets: an absolutely positioned child of a flex container is put
// at the container's start corner, not where it would have sat, so without these the search
// icon jumps across the bar and lands on top of the logo.
// the block offsets plus auto margins centre them the way align-items did
position: absolute;
top: $barPaddingTop;
bottom: $barPaddingBottom;
margin-block: auto;
// they're on their way out or not properly back yet, so they take no clicks
pointer-events: none;
}
.ds2-top-nav__logo {
left: $barPaddingInline;
}
// its right edge lands where the burger's left edge is, which is where it sits in the flow
.ds2-top-nav__search-toggle {
right: $barPaddingInline + $menuButtonSize;
}
}
&--search-opening {
.ds2-top-nav__logo,
.ds2-top-nav__search-toggle {
animation: ds2-top-nav-fade-out $searchSlideDuration ease forwards;
}
.ds2-top-nav__search-close {
animation: ds2-top-nav-fade-in $searchSlideDuration ease;
}
}
// the same animation backwards. Nothing in here is interactive: focus has already gone back to
// the icon and the box is on its way out, so a click or a tab must not land in it
&--search-closing {
.ds2-top-nav__logo,
.ds2-top-nav__search-toggle {
animation: ds2-top-nav-fade-in $searchSlideDuration ease;
}
.ds2-top-nav__search-close {
pointer-events: none;
animation: ds2-top-nav-fade-out $searchSlideDuration ease forwards;
}
.ds2-top-nav__search {
pointer-events: none;
// its own keyframe rather than the grow one reversed: same name means the browser treats it
// as the animation that has already finished and never restarts it.
// forwards holds the end state until the JS takes the class off, so it can't flash back to full width
animation: ds2-top-nav-search-shrink $searchSlideDuration ease forwards;
}
}
// desktop, 1280 and up: one row with the logo, nav and search, and a mega-menu dropdown
// only with JS, as without it the dropdowns couldn't open, so the plain stacked lists are kept instead
@media (min-width: $desktopBreakpoint) {
&--js {
// content lines up to a centred column up to 1400px wide, the background stays full width
$contentInset: unquote("max(#{$desktopPaddingInline}, calc((100% - #{$desktopMaxWidth}) / 2))");
// the top row's children join this grid (the bar uses display: contents)
// the nav is right-aligned against the search column, which is only as wide as the icon or the open search box,
// so opening search pushes the links along towards the logo
display: grid;
// equal side columns, so the links are centred on the page rather than in whatever space is
// left between the logo and the search. The logo is much wider than the search icon, so
// centring them in the leftover space would sit them noticeably right of centre
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
column-gap: $desktopColumnGap;
align-items: center;
min-height: $desktopBarHeight;
padding-inline: $contentInset;
.ds2-top-nav__bar {
display: contents;
}
.ds2-top-nav__logo {
grid-column: 1;
grid-row: 1;
justify-self: start;
margin: 0;
padding: 0;
}
.ds2-top-nav__logo-crest {
width: $desktopLogoHeight;
height: $desktopLogoHeight;
}
.ds2-top-nav__logo-wordmark {
height: $desktopLogoHeight;
}
.ds2-top-nav__menu-toggle {
display: none;
}
// search: an icon at the right that expands into the search box
.ds2-top-nav__search-toggle {
grid-column: 3;
grid-row: 1;
justify-self: end;
}
.ds2-top-nav__search {
display: none;
grid-column: 3;
grid-row: 1;
justify-self: end;
width: $desktopSearchWidth;
max-width: none;
height: $desktopSearchHeight;
margin: 0;
}
// nav: level 1 in a row, centred in its own column
.ds2-top-nav__nav {
display: block;
position: static;
grid-column: 2;
grid-row: 1;
justify-self: center;
min-height: 0;
padding: 0;
background: none;
overflow: visible;
}
.ds2-top-nav__list--level-1 {
flex-direction: row;
gap: 0;
max-width: none;
padding: 0;
> .ds2-top-nav__item > .ds2-top-nav__link {
width: auto;
// full height of the bar so the whole bar is the hover area, and the dropdown starts right below it
min-height: $desktopBarHeight;
padding: 0 $desktopLinkPaddingFluid;
&[aria-expanded="true"] {
color: $highlightColour;
}
// points down when closed and up when open
> .ds2-top-nav__arrow {
transform: rotate(90deg);
transition: transform $arrowTurnDuration ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&[aria-expanded="true"] > .ds2-top-nav__arrow {
transform: rotate(-90deg);
}
}
}
// no back buttons on desktop, level 2 and 3 show side by side
.ds2-top-nav__back {
display: none;
}
// level 2: a full width dropdown below the bar, positioned against the header
.ds2-top-nav__panel--level-2 {
display: none;
position: absolute;
z-index: $menuZIndex;
top: 100%;
left: 0;
right: 0;
width: auto;
height: auto;
min-height: 0;
overflow: visible;
padding: $desktopPanelPaddingTop $contentInset $desktopPanelPaddingBottom;
transform: none;
visibility: visible;
transition: none;
// in forced colours the black background becomes the page colour, so the dropdown
// would merge into the page below it. This border gives it a visible edge (supports WCAG 1.4.11)
@media (forced-colors: active) {
border-bottom: 1px solid CanvasText;
}
}
// level 3: beside the level 2 column, positioned against the level 2 list
.ds2-top-nav__panel--level-3 {
display: none;
position: absolute;
top: 0;
left: 100%;
width: 100%;
height: auto;
min-height: 0;
overflow: visible;
padding: 0;
background: none;
transform: none;
visibility: visible;
transition: none;
}
.ds2-top-nav__panel--open {
display: block;
}
.ds2-top-nav__list--level-2 {
position: relative;
gap: 0;
width: $desktopLevel2Width;
max-width: none;
padding: 0;
}
.ds2-top-nav__list--level-3 {
gap: 0;
max-width: none;
padding: 0;
}
.ds2-top-nav__panel .ds2-top-nav__link {
min-height: $desktopRowHeight;
padding: 0;
// the dropdown has room round it, so the focus ring can go outside the row like everywhere else
&:focus-visible {
outline-offset: $focusOutlineOffset;
}
&[aria-expanded="true"] {
color: $highlightColour;
}
}
// level 2 items with children: a highlight coloured arrow whose line grows across to the level 3 column when open,
// and on hover too unless hover has been turned off, when it would point at a panel that isn't going to open
.ds2-top-nav__list--level-2 > .ds2-top-nav__item > .ds2-top-nav__link {
padding-right: $desktopArrowToLevel3Gap;
.ds2-top-nav__link-text {
flex: none;
}
> .ds2-top-nav__arrow {
flex: 0 1 $desktopLevel2ArrowWidth;
color: $highlightColour;
transition: flex-grow $arrowGrowDuration ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&[aria-expanded="true"] > .ds2-top-nav__arrow {
flex-grow: 1;
}
}
// the growth on hover follows the same attribute the JS reads, and has to sit out here so
// the :not() lands on the header itself. Nested inside the rule above it would compile to a
// descendant selector, and since --js is on that same element it could never match.
// with hover off the arrow grows only for an open panel, never towards empty space
&:not([data-desktop-hover="false"]) {
@media (hover: hover) {
.ds2-top-nav__list--level-2 > .ds2-top-nav__item > .ds2-top-nav__link:hover > .ds2-top-nav__arrow {
flex-grow: 1;
}
}
}
}
// desktop: the open box fills the space the nav links leave, so it grows by max-width
// display is set again here because the rule hiding it above sits later in the file than the shared search-open rule
&--search-open,
&--search-closing {
// logo | everything else, replacing logo | nav | search. The arrow and the box share that
// second column as one flex row, so they move together.
// this rule sits after &--js, which sets the three columns, and both classes are on the
// same element
grid-template-columns: auto minmax(0, 1fr);
.ds2-top-nav__search-toggle {
display: none;
}
// the logo stays on desktop: there's room for it, and it's the nav links that make way
.ds2-top-nav__logo {
display: inline-flex;
}
// the links give up their column to the box
// display: none, not visibility, so they leave the tab order rather than being invisible and still reachable
.ds2-top-nav__nav {
display: none;
}
// the arrow and the box share one flex row, spanning the slack column and the box's own,
// and it's packed to the right. That's what makes the box open leftwards out of the icon
// and the arrow travel with its left edge instead of waiting at a fixed column boundary
.ds2-top-nav__search-group {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $desktopColumnGap;
grid-column: 2;
grid-row: 1;
}
.ds2-top-nav__search-close {
// it's a flex item here, not a grid one
grid-column: auto;
justify-self: auto;
}
// takes the room the links left, up to its cap, and no further
.ds2-top-nav__search {
display: flex;
flex: 1;
width: auto;
max-width: $desktopSearchMaxWidth;
justify-self: auto;
margin-inline: 0;
}
}
&--search-open .ds2-top-nav__search {
animation: ds2-top-nav-desktop-search-grow $searchSlideDuration ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
&--search-closing .ds2-top-nav__search {
animation: ds2-top-nav-desktop-search-shrink $searchSlideDuration ease forwards;
}
// desktop takes no part in the cross-fade: the offsets above are measured from the mobile bar,
// and here the bar is display: contents so there is nothing to position against
&--search-opening,
&--search-closing {
.ds2-top-nav__logo {
position: static;
inset: auto;
margin-block: 0;
animation: none;
pointer-events: auto;
}
}
// on the way out the icon comes back straight away, in the same cell as the shrinking box and
// against the same right edge, so the box appears to collapse into it.
// it has to be focusable by then: closing returns focus here, and a display: none element
// can't take focus, which left focus on the body
&--search-closing {
.ds2-top-nav__search-toggle {
display: inline-flex;
grid-column: 2;
grid-row: 1;
justify-self: end;
}
}
// opening is the other way round: it goes as the box arrives
&--search-opening {
.ds2-top-nav__search-toggle {
display: none;
}
}
}
// search box slides, see $searchSlideDuration
// the build's CSS minifier renames keyframes (e.g. to "j"), so search the compiled CSS for the animation rule, not these names
// separate names on purpose: an animation with the same name on the same element isn't restarted
@keyframes ds2-top-nav-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes ds2-top-nav-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// the way back out. It can't be the grow keyframe reversed: an animation with the same name
// on the same element isn't restarted, so nothing would happen
@keyframes ds2-top-nav-search-shrink {
from {
max-width: $searchSlideEndWidth;
opacity: 1;
}
67% {
opacity: 1;
}
to {
max-width: $iconButtonSize;
opacity: 0;
}
}
// grows outwards from the middle of the bar, because the box is centred by its auto margins.
// the fade is over the first third, so it doesn't start life as a bare sliver of border
@keyframes ds2-top-nav-search-grow {
from {
max-width: $iconButtonSize;
opacity: 0;
}
33% {
opacity: 1;
}
to {
max-width: $searchSlideEndWidth;
opacity: 1;
}
}
// desktop: the box has a fixed width, so its width can be animated directly
// the open desktop box has no fixed width to animate to, so it grows by max-width like the mobile one
@keyframes ds2-top-nav-desktop-search-grow {
from {
max-width: $iconButtonSize;
}
to {
max-width: $desktopSearchMaxWidth;
}
}
@keyframes ds2-top-nav-desktop-search-shrink {
from {
max-width: $desktopSearchMaxWidth;
}
to {
max-width: $iconButtonSize;
}
}
// text for screen readers only: it names the icon buttons and says where each back button goes
// display: none would hide it from them too, so it stays in the page (WCAG 1.1.1, 2.4.6)
// kept inside the component so it has no global dependency
&__visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
// Burger menu with drill-down panels up to 80rem, mega-menu above it.
// This file holds the state; how each state looks is in _ds2-top-nav.scss.
// The buttons are disclosures with aria-expanded, not ARIA menus, and hover is
// always an extra on top of click and keyboard. Aiming at WCAG 2.2 AA.
// must match $desktopBreakpoint in the SCSS
// in rem so it moves with the reader's text size: 80rem is 1280px at the usual 16px text
const desktopMediaQuery = '(min-width: 80rem)';
const openMenuClass = 'ds2-top-nav--menu-open';
// the search box shows only in this state, on every screen size
const openSearchClass = 'ds2-top-nav--search-open';
const openPanelClass = 'ds2-top-nav__panel--open';
// held for the length of the closing animation, because a closed search box is display: none
// and so has nothing to animate out of
const closingSearchClass = 'ds2-top-nav--search-closing';
// its opposite number: held while the box opens, so the logo and the search icon can fade out
// instead of blinking away
const openingSearchClass = 'ds2-top-nav--search-opening';
// desktop hover timings in ms. The open delay stops dropdowns flashing as the pointer passes,
// and level 2 waits longer so moving diagonally to the level 3 links doesn't switch item.
// The close delay lets the pointer slip off and come back, which also keeps hover content
// hoverable and persistent (WCAG 1.4.13).
const hoverOpenDelay = 100;
const hoverOpenDelayLevel2 = 200;
const hoverCloseDelay = 300;
// must match $searchSlideDuration in the SCSS
const searchSlideDuration = 400;
export const ds2Header = () => {
const desktopQuery = window.matchMedia(desktopMediaQuery);
document.querySelectorAll('.ds2-top-nav').forEach((header) => {
// don't set up the same header twice if the init function is called again
if (header.classList.contains('ds2-top-nav--js')) return;
initTopNav(header, desktopQuery);
});
};
const initTopNav = (header, desktopQuery) => {
const nav = header.querySelector('.ds2-top-nav__nav');
if (!nav) return;
const menuToggle = header.querySelector('.ds2-top-nav__menu-toggle');
const searchToggle = header.querySelector('.ds2-top-nav__search-toggle');
const searchClose = header.querySelector('.ds2-top-nav__search-close');
// desktop dropdowns open on hover as well as click unless the markup says otherwise.
// the attribute is only written when it's off, so anything without it keeps hover.
// it's read per header, so two headers on a page can differ
const hoverOpens = header.dataset.desktopHover !== 'false';
// where the nav sits in the markup, so it can be put back exactly as it was below the breakpoint.
// it's a child of the header, while the logo and the search sit inside the bar, so moving it
// between them means moving it into the bar
const bar = header.querySelector('.ds2-top-nav__bar');
const navHome = nav.nextElementSibling;
const searchGroup = header.querySelector('.ds2-top-nav__search-group');
const searchForm = header.querySelector('.ds2-top-nav__search');
const searchInput = header.querySelector('.ds2-top-nav__search-input');
// each disclosure button in the nav controls one panel: the panel is the button's next sibling.
// pairing them here, rather than in the markup, keeps every id and aria-controls out of the
// template, so an implementation in another templating language has only the structure to
// reproduce, with nothing to number. The markup carries no ARIA for these buttons at all,
// which is also the honest state without JS: nothing can open, so nothing claims it can
const panelFor = new Map();
const buttonFor = new Map();
// ids have to be unique on the page. idPrefix in the config is meant to keep two headers apart,
// but two left on the default would share every id, which breaks aria-controls and the label,
// and is a duplicate-id failure in its own right. Rather than rely on remembering, anything
// already taken is given a suffix here.
//
// which of the two keeps the markup's id isn't fixed: whichever is set up first sees the other's
// copy and moves off it. That's fine, because nothing outside the component depends on these ids,
// and it only happens at all when they were already clashing
const freeId = (wanted) => {
if (!document.getElementById(wanted)) return wanted;
let n = 2;
while (document.getElementById(`${wanted}-${n}`)) n += 1;
return `${wanted}-${n}`;
};
// an id the markup set, which another element on the page has already claimed
const isTaken = (el) => el && el.id
&& Array.from(document.querySelectorAll(`[id="${el.id}"]`)).some((other) => other !== el);
// rename it and mend whatever pointed at it
const rename = (el, pointers) => {
if (!isTaken(el)) return;
const to = freeId(el.id);
el.id = to;
pointers.forEach(({ element, attribute }) => {
if (element) element.setAttribute(attribute, to);
});
};
rename(nav, [{ element: menuToggle, attribute: 'aria-controls' }]);
rename(searchForm, [{ element: searchToggle, attribute: 'aria-controls' }]);
rename(searchInput, [{ element: header.querySelector('.ds2-top-nav__search-label'), attribute: 'for' }]);
// the panels are numbered from the nav's id, so this reads it after any renaming above
const idBase = nav.id || `${header.id || 'ds2-top-nav'}-menu`;
let panelNumber = 0;
Array.from(nav.querySelectorAll('.ds2-top-nav__item > button')).forEach((button) => {
const panel = button.nextElementSibling;
if (panel && panel.classList.contains('ds2-top-nav__panel')) {
panelFor.set(button, panel);
buttonFor.set(panel, button);
panelNumber += 1;
if (!panel.id) panel.id = freeId(`${idBase}-panel-${panelNumber}`);
button.setAttribute('aria-controls', panel.id);
// the state lives on the button, not the panel (WCAG 4.1.2)
// it isn't in the markup, so without JS nothing is announced as collapsed while its links show
button.setAttribute('aria-expanded', 'false');
}
});
// panels opened by hovering, so only these close again when the pointer leaves
// a clicked panel stays open until it's clicked again, Escape, or a click outside
const openedByHover = new Set();
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let searchClosingTimer;
const hoverTimers = new Map();
const isDesktop = () => desktopQuery.matches;
const isMenuOpen = () => header.classList.contains(openMenuClass);
const isSearchOpen = () => header.classList.contains(openSearchClass);
const isExpanded = (button) => button.getAttribute('aria-expanded') === 'true';
const isTopLevel = (button) => !button.closest('.ds2-top-nav__panel');
const openButtons = () => Array.from(panelFor.keys()).filter(isExpanded);
// the disclosure button that belongs directly to an item, not one in a nested panel
const itemButton = (item) => {
const button = item.querySelector(':scope > button');
return button && panelFor.has(button) ? button : null;
};
const clearHoverTimer = (button) => {
clearTimeout(hoverTimers.get(button));
hoverTimers.delete(button);
};
// on mobile and tablet a panel slides over the list it came from, so what it covers is made inert:
// the other items, the button that opened it, and the back button of the panel above.
// Otherwise keyboard and screen reader users reach items hidden behind the panel (WCAG 2.4.11, 4.1.2).
// inert goes on the siblings because a descendant can't undo it.
// inert is the one modern feature here with an accessibility consequence rather than a cosmetic
// one: without it, a keyboard or screen reader user can reach the links behind an open panel.
// Firefox only got it in April 2023, so it's the thing to check if that ever starts happening
const setCoveredInert = (button, covered) => {
const item = button.closest('.ds2-top-nav__item');
if (!item || !item.parentElement) return;
Array.from(item.parentElement.children).forEach((sibling) => {
if (sibling !== item) sibling.inert = covered;
});
button.inert = covered;
const parentPanel = item.closest('.ds2-top-nav__panel');
const parentBack = parentPanel && parentPanel.querySelector(':scope > .ds2-top-nav__back');
if (parentBack) parentBack.inert = covered;
};
const closePanel = (button) => {
const panel = panelFor.get(button);
// close nested panels first so nothing is left open inside a closed panel
Array.from(panel.querySelectorAll('button[aria-expanded="true"]')).forEach((nestedButton) => {
if (panelFor.has(nestedButton)) closePanel(nestedButton);
});
clearHoverTimer(button);
openedByHover.delete(button);
button.setAttribute('aria-expanded', 'false');
panel.classList.remove(openPanelClass);
setCoveredInert(button, false);
};
const closeAllPanels = () => {
openButtons().forEach(closePanel);
};
const openPanel = (button) => {
const panel = panelFor.get(button);
const item = button.closest('.ds2-top-nav__item');
// only one panel open per list
if (item && item.parentElement) {
Array.from(item.parentElement.children).forEach((sibling) => {
const siblingButton = itemButton(sibling);
if (sibling !== item && siblingButton && isExpanded(siblingButton)) {
closePanel(siblingButton);
}
});
}
button.setAttribute('aria-expanded', 'true');
panel.classList.add(openPanelClass);
// the panel slides over the button, so focus moves into it (WCAG 2.4.3)
// leaving focus on the covered button would strand keyboard users behind the panel
if (!isDesktop()) {
setCoveredInert(button, true);
// fall back to the back button, so focus never lands on the button just made inert
const firstItem = panel.querySelector(':scope > .ds2-top-nav__list a[href], :scope > .ds2-top-nav__list button')
|| panel.querySelector(':scope > .ds2-top-nav__back');
if (firstItem) firstItem.focus();
}
};
// the icon's aria-expanded says whether the box is showing (WCAG 4.1.2)
const syncSearchToggle = () => {
if (searchToggle) searchToggle.setAttribute('aria-expanded', String(isSearchOpen()));
};
// the icon expands into the search box
// on desktop the box takes the place of the nav links, so any open dropdown goes with them
const setSearchOpen = (open) => {
if (open && isDesktop()) closeAllPanels();
const wasOpen = isSearchOpen();
header.classList.toggle(openSearchClass, open);
syncSearchToggle();
// play the opening animation backwards on the way out, then let the box go back to display: none.
// reduced motion skips it, so the box doesn't linger for no reason
clearTimeout(searchClosingTimer);
header.classList.remove(openingSearchClass);
if (!open && wasOpen && !reducedMotion.matches) {
header.classList.add(closingSearchClass);
// it's on its way out, so nothing in it should take focus in the meantime
if (searchForm) searchForm.inert = true;
searchClosingTimer = setTimeout(() => {
header.classList.remove(closingSearchClass);
if (searchForm) searchForm.inert = false;
}, searchSlideDuration);
} else if (open && !wasOpen && !reducedMotion.matches) {
header.classList.remove(closingSearchClass);
if (searchForm) searchForm.inert = false;
header.classList.add(openingSearchClass);
searchClosingTimer = setTimeout(() => {
header.classList.remove(openingSearchClass);
}, searchSlideDuration);
} else {
header.classList.remove(closingSearchClass);
if (searchForm) searchForm.inert = false;
}
};
// the menu and the search box are independent: the burger opens and closes the menu and nothing else.
// closing the menu used to put the search box away too, which was left over from when the search icon
// opened the menu as well. It hasn't since 2026-09-14
const setMenuOpen = (open) => {
header.classList.toggle(openMenuClass, open);
if (menuToggle) menuToggle.setAttribute('aria-expanded', String(open));
if (!open) closeAllPanels();
};
// clicking or tabbing away only closes search if nothing has been typed, so text isn't hidden
const closeSearchIfEmpty = () => {
if (isSearchOpen() && !(searchInput && searchInput.value)) setSearchOpen(false);
};
// close everything, used for clicking outside and focus leaving the header
const closeAll = () => {
hoverTimers.forEach((timer) => clearTimeout(timer));
hoverTimers.clear();
closeAllPanels();
setMenuOpen(false);
closeSearchIfEmpty();
};
panelFor.forEach((panel, button) => {
button.addEventListener('click', () => {
clearHoverTimer(button);
if (isExpanded(button)) {
// a click on an open panel always closes it, however it was opened
// (a hover-opened panel used to stay open when clicked; removed 2026-09-21)
closePanel(button);
} else {
openPanel(button);
}
});
// desktop hover is mouse only, so a tap is always a click and nothing needs hover to work
// (pointerenter fires for touch too, hence the check)
const item = button.closest('.ds2-top-nav__item');
if (!item) return;
// the hover handlers aren't attached at all when hover is off, rather than bailing out inside
// them, so nothing can half-happen and openedByHover is simply never filled
if (!hoverOpens) return;
item.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse' || !isDesktop()) return;
clearHoverTimer(button);
if (isExpanded(button)) return;
hoverTimers.set(button, setTimeout(() => {
hoverTimers.delete(button);
openPanel(button);
openedByHover.add(button);
}, isTopLevel(button) ? hoverOpenDelay : hoverOpenDelayLevel2));
});
item.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse' || !isDesktop()) return;
clearHoverTimer(button);
// level 3 stays open after the pointer leaves its level 2 item, until another item is chosen or the dropdown closes
if (isTopLevel(button) && openedByHover.has(button)) {
hoverTimers.set(button, setTimeout(() => {
hoverTimers.delete(button);
if (openedByHover.has(button)) closePanel(button);
}, hoverCloseDelay));
}
});
});
// back buttons return to the panel above and focus the button that opened this one,
// so people carry on from where they were rather than at the top of the menu (WCAG 2.4.3)
Array.from(nav.querySelectorAll('.ds2-top-nav__back')).forEach((backButton) => {
const panel = backButton.closest('.ds2-top-nav__panel');
const button = panel && buttonFor.get(panel);
if (!button) return;
backButton.addEventListener('click', () => {
closePanel(button);
button.focus();
});
});
if (menuToggle) {
menuToggle.addEventListener('click', () => {
setMenuOpen(!isMenuOpen());
});
}
if (searchToggle) {
searchToggle.addEventListener('click', () => {
// the icon only opens the search box, it never opens the menu
setSearchOpen(true);
if (searchInput) searchInput.focus();
});
}
// closing puts focus back on the icon, which is where the user started and is visible again by now
if (searchClose) {
searchClose.addEventListener('click', () => {
setSearchOpen(false);
if (searchToggle) searchToggle.focus();
});
}
// Escape closes things and puts focus back on the control that opened them (WCAG 2.1.1)
// it's on the document, not the header, so a hover-opened dropdown can be dismissed
// without moving the pointer or having focus in the header (WCAG 1.4.13)
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
// let escape clear a search input that has text in it first
if (event.target === searchInput && searchInput.value) return;
const focusInHeader = header.contains(document.activeElement);
// the search box collapses back to its icon first, on every screen size
if (isSearchOpen()) {
setSearchOpen(false);
if (focusInHeader && searchToggle) searchToggle.focus();
return;
}
if (isDesktop()) {
// desktop: close the open dropdown and return focus to its top level button
const topButton = openButtons().find(isTopLevel);
if (!topButton) return;
closeAllPanels();
if (focusInHeader) topButton.focus();
} else if (isMenuOpen()) {
// mobile and tablet: close the whole menu and return focus to the menu button
closeAll();
if (focusInHeader && menuToggle) menuToggle.focus();
}
});
document.addEventListener('click', (event) => {
if (!header.contains(event.target)) closeAll();
});
header.addEventListener('focusin', (event) => {
if (!isDesktop()) return;
// desktop: tabbing to another part of the header closes dropdowns focus has left
openButtons().forEach((button) => {
const item = button.closest('.ds2-top-nav__item');
if (isExpanded(button) && item && !item.contains(event.target)) closePanel(button);
});
// the close button is left out: focusing it would shut an empty box before the click landed,
// taking the button out of the page mid-click and leaving focus nowhere
const movingToClose = searchClose && searchClose.contains(event.target);
if (searchForm && !searchForm.contains(event.target) && !movingToClose) closeSearchIfEmpty();
});
header.addEventListener('focusout', (event) => {
// relatedTarget is null when the window loses focus, keep the menu open in that case
if (event.relatedTarget && !header.contains(event.relatedTarget)) closeAll();
});
// ACCESSIBILITY: tab order follows the markup, and one markup order can't suit both layouts.
// the burger has to come before the menu it opens on mobile, which puts the search ahead of the
// links on desktop, where you'd then tab from the logo out to the search icon and back to the
// middle for the nav. So the nav is moved ahead of the search on desktop and put back below the
// breakpoint (WCAG 2.4.3). Moving a node keeps its listeners, and the nav is positioned against
// the header on mobile, so nothing shifts on screen either way
const placeNav = () => {
if (!bar || !searchGroup) return;
if (isDesktop()) {
// into the bar, between the logo and the search. The bar is display: contents up here,
// so the nav still takes its place in the header's grid
if (nav.parentNode !== bar || nav.nextElementSibling !== searchGroup) {
bar.insertBefore(nav, searchGroup);
}
} else if (nav.parentNode !== header || nav.nextElementSibling !== navHome) {
header.insertBefore(nav, navHome);
}
};
// start each layout clean when crossing the desktop breakpoint
desktopQuery.addEventListener('change', () => {
// closing first, so focus is never inside the nav while it's being moved
closeAll();
setSearchOpen(false);
placeNav();
});
// matchMedia only fires on a change, so the starting width has to be handled here
placeNav();
setMenuOpen(false);
header.classList.add('ds2-top-nav--js');
};
{
"idPrefix": "ds2-top-nav",
"desktopHover": true,
"navLabel": "Main",
"menuLabel": "Menu",
"logo": {
"url": "https://www.leeds.ac.uk/",
"label": "University of Leeds homepage"
},
"search": {
"action": "https://www.leeds.ac.uk/search",
"inputName": "q",
"label": "Search leeds.ac.uk",
"placeholder": "Search",
"submitLabel": "Search",
"openLabel": "Search",
"closeLabel": "Close search",
"hiddenFields": [
{
"name": "searchOption",
"value": "searchSite"
}
]
},
"level1Options": [
{
"title": "Study",
"level2Options": [
{
"title": "Course search",
"url": "https://courses.leeds.ac.uk/"
},
{
"title": "Undergraduate",
"level3Options": [
{
"title": "Undergraduate home page",
"url": "https://www.leeds.ac.uk/undergraduate"
},
{
"title": "Clearing 2026",
"url": "https://www.leeds.ac.uk/clearing"
},
{
"title": "Undergraduate course search",
"url": "https://courses.leeds.ac.uk/course-search/undergraduate-courses"
},
{
"title": "Fees and funding",
"url": "https://www.leeds.ac.uk/undergraduate-fees/doc/fees-undergraduate-fees"
},
{
"title": "Student life",
"url": "https://www.leeds.ac.uk/undergraduate-student-life/doc/students-union"
}
]
},
{
"title": "Online courses",
"level3Options": [
{
"title": "Overview",
"url": "https://www.leeds.ac.uk/online-courses"
},
{
"title": "Online Masters study",
"url": "https://www.leeds.ac.uk/online-masters-study"
},
{
"title": "Short online courses",
"url": "https://www.leeds.ac.uk/short-online-courses"
},
{
"title": "PhD by distance learning",
"url": "https://www.leeds.ac.uk/online-courses/doc/phd-distance-learning"
}
]
},
{
"title": "Masters",
"level3Options": [
{
"title": "Masters home page",
"url": "https://www.leeds.ac.uk/masters"
},
{
"title": "Postgraduate open day",
"url": "https://www.leeds.ac.uk/postgraduate-events"
},
{
"title": "Masters course search",
"url": "https://courses.leeds.ac.uk/course-search/masters-courses?type=PGT&query="
},
{
"title": "Applying",
"url": "https://www.leeds.ac.uk/masters-applying/doc/apply-masters-courses"
},
{
"title": "Fees and funding",
"url": "https://www.leeds.ac.uk/masters-offer/doc/masters-fees-funding"
}
]
},
{
"title": "Research degrees",
"url": "https://www.leeds.ac.uk/research-degrees"
},
{
"title": "International students",
"url": "https://www.leeds.ac.uk/international-students"
},
{
"title": "Foundation courses",
"url": "https://www.leeds.ac.uk/foundation-courses"
},
{
"title": "Mature students",
"url": "https://www.leeds.ac.uk/mature-students"
}
]
},
{
"title": "Faculties",
"level2Options": [
{
"title": "Arts, Humanities and Cultures",
"url": "https://ahc.leeds.ac.uk/"
},
{
"title": "Biological Sciences",
"url": "https://biologicalsciences.leeds.ac.uk/"
},
{
"title": "Business School",
"url": "https://business.leeds.ac.uk/"
},
{
"title": "Engineering and Physical Sciences",
"url": "https://eps.leeds.ac.uk/"
},
{
"title": "Environment",
"url": "https://environment.leeds.ac.uk/"
},
{
"title": "Medicine and Health",
"url": "https://medicinehealth.leeds.ac.uk/"
},
{
"title": "Social Sciences",
"url": "https://essl.leeds.ac.uk/"
},
{
"title": "Faculty contacts",
"url": "https://www.leeds.ac.uk/about/doc/faculties-contacts"
}
]
},
{
"title": "Research",
"level2Options": [
{
"title": "Research overview",
"url": "https://www.leeds.ac.uk/research-and-innovation"
},
{
"title": "Research excellence and impact",
"url": "https://www.leeds.ac.uk/research-impact"
},
{
"title": "Strategy and environment",
"url": "https://www.leeds.ac.uk/research-strategy-environment"
},
{
"title": "Researcher and staff development",
"url": "https://www.leeds.ac.uk/researcher-staff-development"
},
{
"title": "Public engagement",
"url": "https://www.leeds.ac.uk/research-public-engagement"
},
{
"title": "Research and innovation contacts",
"url": "https://www.leeds.ac.uk/research-and-innovation/doc/research-innovation-contacts"
}
]
},
{
"title": "Business",
"level2Options": [
{
"title": "Overview",
"url": "https://www.leeds.ac.uk/business-partnerships"
},
{
"title": "Access our expertise",
"url": "https://www.leeds.ac.uk/business-partnerships-access-expertise/doc/work-with-academics"
},
{
"title": "Training and development",
"url": "https://www.leeds.ac.uk/training-development/doc/cpd-professional-learning-businesses-employers"
},
{
"title": "Recruit our students and graduates",
"url": "https://www.leeds.ac.uk/business-partnerships/doc/recruit-students"
},
{
"title": "Use our facilities",
"url": "https://www.leeds.ac.uk/business-partnerships/doc/use-facilities"
},
{
"title": "Support for small businesses",
"url": "https://www.leeds.ac.uk/business-partnerships/doc/support-small-businesses"
},
{
"title": "Our business partners",
"url": "https://www.leeds.ac.uk/business-partnerships/doc/business-partners"
}
]
},
{
"title": "About",
"level2Options": [
{
"title": "Campus map",
"url": "https://www.leeds.ac.uk/campusmap"
},
{
"title": "Our history",
"url": "https://www.leeds.ac.uk/about/doc/about-history"
},
{
"title": "Faculties",
"url": "https://www.leeds.ac.uk/our-faculties"
},
{
"title": "Governance",
"url": "https://www.leeds.ac.uk/structure-governance/doc/governance"
},
{
"title": "Reports, accounts and policies",
"url": "https://www.leeds.ac.uk/structure-governance/doc/reports-accounts-policies"
},
{
"title": "Strategies and values",
"url": "https://www.leeds.ac.uk/about/doc/strategies-values"
},
{
"title": "Equality",
"url": "https://equality.leeds.ac.uk/"
},
{
"title": "Executive team",
"url": "https://www.leeds.ac.uk/university-executive"
},
{
"title": "News",
"url": "https://www.leeds.ac.uk/university-news"
},
{
"title": "Events",
"url": "https://www.leeds.ac.uk/events/"
}
]
},
{
"title": "Alumni",
"url": "https://www.leeds.ac.uk/alumni"
}
]
}