Skip to main content

Navigation

Wayfinding, at any width

Every component on this page responds to its container, not to the browser window. Use the width buttons on each specimen to check the narrow state — which is where responsive bugs live and where nobody looks unless it takes two seconds.

Information: Why the width switcher works at all

The stage below each toolbar is narrowed with inline-size. A component whose layout comes from @container measures the stage and responds. One using @media measures the window, which has not changed — so the buttons would visibly do nothing. That is the practical reason DDS requires container queries for anything with layout-shifting behaviour.


Pagination

Navigation between pages of a list, as real links with real hrefs, so a page can be bookmarked, shared and opened in a new tab.

Each name says where it goes — “Page 3”, not “3”.

Below 28rem of container width: the numbers stop wrapping and scroll instead, so Previous and Next stay on the line with them. Wrapping put “Previous” alone on the first line and read as three controls rather than one. No page is dropped and nothing hides behind a “more” control — the strip needs no tab stop of its own because every child in it is a link, and focusing a link scrolls it into view.

Step progress

Each step states its status in words. A row of coloured dots communicates nothing without sight, and “which step am I on” is essential orientation rather than decoration.

  1. Your details Completed
  2. Address Completed
  3. Documents Current step
  4. Review and submit Not started

Stacked below 34rem of container width, horizontal above it. Completed steps may be links if the user is allowed back; upcoming ones never are.

Toolbar

Row density

The menu is a popover invoked by popovertarget — no JavaScript. Light dismiss, Escape, top-layer rendering and focus return all come from the platform.

Narrow: the row wraps, and the rule between groups sits on the trailing edge of the group before the gap. On the leading edge of the one after it, a wrapped group began the new line with a vertical rule separating it from nothing. Groups keep their spacing either way, so the grouping survives the break even where the rule does not fall between two groups on one line.

Filter bar

Controls that narrow a list. A real form, so the state can live in the URL and the result is shareable.

Applied: Status: Approved Owner: Nadja Öztürk

Applied filters must always be visible as removable chips. A short list with three invisible filters applied is the most common dead end in a filtered interface — the user concludes the data is missing.

Action bar

A sticky bar carrying the actions for what is on screen — a form's Save, a selection's operations.

Draft saved vor 2 Minuten

In use it is position: sticky with env(safe-area-inset-bottom), and the scrolling ancestor carries .dds-actionbar-host so the bar never covers a field that has just been focused (WCAG 2.2 2.4.11).

Table of contents

The active entry is aria-current="location", not "page". The user has not navigated anywhere — the reading position moved.

The reading position is measured against a line a quarter of the way down the viewport, not reported by an IntersectionObserver. A band answers “what is inside it”, and at the bottom of a page nothing is — so the last entry could never activate. Scroll this page and watch the marker follow.

It marks a section only where this list is visible while that section scrolls past — where it, or an ancestor, is sticky. A list that scrolls away with the content can only be seen in one state: whatever was current when it was last on screen, which is always the first entry. That reads as a selection, and announced it claims a reading position that never changes. On a phone, where the contents list above sits in the flow, no entry is marked at all.

Without JavaScript the list is a set of working anchor links. Only the highlight is lost, which is the correct thing to lose.

Content navigation

Four navigation components, four different jobs. Content navigation moves you between the pages of one body of content; app navigation moves you between areas of a signed-in application; the site header moves you between top-level sections; the table of contents moves you within a single page.

Deadlines and dates

Applications open on 1 September and close at 23:59 on 31 October. Anything submitted after that waits for the following round.

Narrow the width switcher above to below 64rem: the column becomes a panel and the button appears. Open it, then press Escape — focus goes back to the button, not to the top of the page.

While the panel is open, try tabbing. This text is inert, so it is out of the tab order and out of the accessibility tree — not merely covered. This link is here to try it on: it is reachable now and unreachable while the panel is open.

Why this is not a <dialog>

A modal panel is the right behaviour at narrow widths, and showModal() would normally be how to get it. It cannot be used here: the same element has to be a static column above the threshold, and the browser closes a dialog with dialog:not([open]) { display: none }. Overriding that is precisely the bug scripts/check-css.mjs now guards against.

The alternative — a dialog for narrow and a separate column for wide — means two copies of the same navigation, which drift apart and are both announced by a screen reader. So it stays one <nav>, and the part that actually mattered comes from the platform anyway: inert on the content.

App navigation

The areas of a signed-in application, as opposed to the pages of one body of content.

inert is what a focus trap was always approximating. A hand-written trap cycles Tab inside the panel while a screen reader walks straight into the content behind it, reading a page the user cannot see. It also has to track the first and last focusable element, which is wrong the moment something inside the panel expands.

App navigation

Flat, for a signed-in application. No groups and no off-canvas mechanic — if it needs grouping it has outgrown this and wants a content navigation; if it needs to collapse, the app is a site and wants a header.

The current area carries aria-current="page", and shows it three ways: a fill, a heavier weight and a coloured label. A fill alone disappears in forced-colors mode and in greyscale.

Icons are aria-hidden — the label carries the meaning. An icon-only app navigation forces every user to learn a private symbol vocabulary, and gives a screen-reader user nothing at all.

The column itself is .dds-sidebar from the layout primitives, not part of this component. The navigation is only the navigation, so it can also sit inside a topbar layout or a panel.

App topbar

Application chrome, as opposed to site chrome. A site header is for getting between pages; a topbar names what you are looking at, holds the actions that apply to it, and stays put while the content scrolls.

Harbour redevelopment — phase two

Draft · edited 14 minutes ago by Ilva Bergström

The title is a heading

.dds-topbar-title goes on a real heading element, so the object's name is in the document outline. A styled div looks identical and leaves a screen-reader user with no way to find out what they are editing.

It truncates with an ellipsis rather than wrapping, so a long name cannot push the actions off the screen. Truncation is only safe because the full name is also the page heading below — a topbar is the summary, never the only place the name appears.

Below 34rem of container width: the actions take a row of their own and wrap among themselves; the menu button and the title keep the first row. The title only truncates because .dds-topbar-context has flex-basis: 0 — a flex container breaks lines on each item's hypothetical size, so with auto the context wrapped to its own line at its full max-content width and shrinking was never considered. An item has to be willing to be narrow, not merely able to be.