Show codeHide code
src/nav - 9 files
Navigation is mostly behaviour, and behaviour is the consumer's. Every component here styles an ARIA attribute rather than a class, so the thing the eye sees and the thing a screen reader announces cannot drift apart.
Every example below is operable. Open the menus, switch the tabs with the arrow keys,
push the drawer open and close it with Escape, click a page number and watch
aria-current move. The bar and the strip sit in frames with
a viewport of their own and a slider under them, because their shape is keyed to a
media query and no amount of narrowing a div inside this document changes what a media
query in this document evaluates to.
The markup panels below are read out of the live examples by site.js, and the frames and width controls come from src/behaviour/demo-stage.js. With scripting off every example still renders, the framed ones render inline at their natural width, and the panels stay empty.
Collapses to a brand plus a disclosure toggle at 320px and opens out to a horizontal row at 48em. The bar at the top of this page is the same component. The example below has a viewport of its own, so the slider crosses 48em without the window moving: drag it left and the row folds into the toggle, then press the toggle and the list opens under the brand.
aria-expanded on the toggle is the single source of truth
for the open state, and the CSS reads that attribute directly rather than a class, so
the visual state and the announced state cannot disagree. The current item changes
weight and carries an accent bar as well as a wash, so the state is not signalled by
colour alone. Focus is never trapped: this is a disclosure, not a modal.
The standalone navigation link, for a footer column or a row of utility links, where a
link needs a real target size and a current state without pulling in a whole bar.
Named bs-navlink because "link" is a reserved scale name:
core already exposes --bs-link-fg on
a[href].
Use --prose for a link inside running text: it keeps the
underline, which is what SC 1.4.1 requires when a link is distinguished from
surrounding text. The default drops the underline because a column of navigation links
is not running text, and puts one back on hover and focus so the interactive state
never rests on colour alone.
A vertical navigation rail for documentation and application sections. Full width at 320px, a column beside the content from 64em. The rail to the left of this text is the same component.
A nested list goes inside the list item it belongs to, not beside it: that is what
makes the hierarchy real rather than a visual indent, and what a screen reader
announces as a nested list. If sections collapse, the group heading is a
button with
aria-expanded, never a paragraph.
The rail's answer for a phone. A vertical rail on a small screen has only bad options: a tall list that pushes the content off the first screen, or a hamburger that hides the structure so nobody finds the other sections. The strip keeps every section permanently visible for the cost of one row, and it is the control a phone user already knows: flick it sideways. The strip under the bar at the top of this page is this component. Drag it, or narrow the box below until it has to scroll.
The list never wraps, because a strip whose items wrap has silently become the tall
list it exists to replace. It snaps on proximity rather than mandatorily, so a small
deliberate drag is not fought.
overscroll-behavior-inline: contain is what stops a flick
that reaches the end from handing its momentum to the document and rubber-banding the
whole page sideways on iOS. --bs-railstrip-bleed defaults
to zero on purpose: only the consumer knows their container's padding, and guessing it
put eight pixels of horizontal scroll on every page here. Add
--mobile-only when the page also renders a vertical rail
past 64em, and leave it off when the strip is the only navigation.
A trail of ancestor pages. It wraps rather than scrolls, because a truncated breadcrumb hides exactly the ancestors it exists to show. Narrow the box and watch the trail take a second line instead of losing a level.
The separator is generated content and is never in the markup: a slash in the DOM makes a screen reader read "Home slash Reports slash EMEA". It is also given an empty accessible name through the alternative-text syntax, so engines that support it do not announce the separator at all.
Almost entirely keyboard behaviour, none of which can come from CSS. Try it: Tab from outside lands on the selected tab and Tab again leaves the strip for the panel. Left and Right move between tabs, Home and End jump to the ends.
Your name, your handle and the address receipts go to.
Card on file, billing period and past invoices.
Keys are shown once when they are created.
Roving tabindex is the part implementations get wrong: exactly one tab is in the tab
sequence, the selected one at tabindex="0" and every other
at -1. Inactive panels are hidden with the
hidden attribute, not a class, because the reset enforces
[hidden] above the component layer. The indicator is the
only animated thing here and it moves on transform alone.
Two different ARIA patterns share this skin, and picking the wrong one is the most
common accessibility defect in a dropdown. A disclosure of links is Tab, Escape and
nothing else. An actions menu carries
role="menu" and owes arrow-key roving. Never put
role="menu" on navigation links: it turns Tab off inside
the panel and makes a screen reader announce links as application commands.
Deliberately no motion. The panel is display toggled, so opening is instantaneous: a screen reader announces it the moment the attribute flips, and an entrance animation would only mean the sighted reader reaches it later than the assistive-technology reader does. Focus is never trapped in either pattern. A menu that traps focus is a dialog wearing the wrong role.
An off-canvas panel that slides in from one edge, built on native
dialog. Open it with
showModal(), never with
show() and never by setting the
open attribute by hand.
showModal() is what gets you, from the browser rather than
from script: focus moved into the dialog and trapped there, everything behind it made
inert, Escape closing it, and the backdrop this file paints.
show() and the bare attribute give you none of that and
leave a dialog a keyboard user can Tab straight out of. Opening slides on transform
alone; the closing slide is the consumer's, because the element leaves the top layer
the instant close() is called.
Page-by-page navigation for a paged list. It wraps at 320px rather than scrolling, so no page number is ever off screen. Click a digit: the current marker moves, and so does the accessible name, because both are attributes rather than paint.
Bare digits are poor link text, so aria-label gives each
link a real accessible name while the visible text stays a digit. The gap is
decorative, its list item carries
aria-hidden="true", and it is three dots rather than an
ellipsis character. Never ship a disabled anchor: it stays clickable. Omit the link or
render a span with aria-disabled="true".