Accessibility

Keyboard order, skip links, focus management across composed layouts, and the ARIA the component set requires.

Accessibility appears throughout this system as measurements — contrast ratios on Colour, the 44px touch floor on Responsive, the focus trap on modals. This page covers what those cannot: order, announcement, and focus — the properties that only exist once components are composed into a screen.

The target is WCAG 2.2 Level AA.

Reading and focus order

There is one order, and the DOM defines it. Keyboard order, screen-reader order, and visual order must agree.

That makes source order a layout constraint, not a detail: write regions in the order they should be read, and let CSS place them. The page shell is therefore authored as skip link → header → sidebar → main → complementary, and Grid puts the sidebar on the left.

Never use order, row-reverse, or grid-area to change the sequence a reader encounters. It moves the pixels and leaves the keyboard behind, which is exactly the bug that a stacked mobile layout exposes: if the DOM is already in reading order, the collapse to one column is free.

Never set a positive tabindex. tabindex="0" puts an element in the natural order and tabindex="-1" makes it programmatically focusable; anything above zero creates a second, competing order.

Every page begins with a skip link — the first focusable element in the DOM, visually hidden until focused, then rendered as a normal control against the midnight header.

One skip link per major landmark the keyboard would otherwise have to traverse. For the page shell that is two: Skip to content and Skip to navigation. A sidebar of thirty items in front of the content is thirty tab stops on every single page.

<a class="pj-skip" href="#main">Skip to content</a>
<a class="pj-skip" href="#nav">Skip to navigation</a>
.pj-skip {
  position: absolute;
  left: var(--space-4);
  top: calc(-1 * var(--space-9));      /* off-screen, still focusable */
  z-index: 100;
  padding: var(--space-2) var(--space-4);
  min-height: 44px;
  background: var(--color-primary);
  color: #fff;
  border-radius: var(--radius-md);
  transition: top var(--duration-standard) var(--ease-out);
}
.pj-skip:focus { top: var(--space-4); }

The target must be able to receive focus: <main id="main" tabindex="-1">. Without it, some browsers move the viewport but leave focus at the top of the document, and the next Tab lands back in the navigation.

Landmarks

The page shell maps onto landmarks one-to-one. Use the elements, not role attributes on divs:

RegionElementNotes
Sidebar<nav aria-label="Sections">The label distinguishes it from any other nav
Header<header>The page title inside it is the <h1>
Content<main id="main" tabindex="-1">Exactly one per page
Secondary panel<aside aria-labelledby="…">Points at the panel’s own heading
Footer<footer>

Two nav elements on a page need two different aria-labels. An unlabelled pair is announced as “navigation, navigation”.

Headings are a real hierarchy, not a type ramp: one <h1> per page, no skipped levels. If a heading is the wrong size, restyle it — do not renumber it.

Focus visibility

Focus is always visible. The system never sets outline: none without replacing it with something at least as loud.

The focus ring is a 2px orange-10 (#cc4528) outline at 2px offset, drawn with :focus-visible so a mouse click on a button does not leave a ring behind while keyboard focus still does.

Focus ring — 2px accent, 2px offset, on both surfaces
Tab to see the real thing
:focus-visible {
  outline: 2px solid var(--color-accent-solid);
  outline-offset: 2px;
}

The offset matters: at 0 the ring sits on the control’s own border and disappears against an outline button, whose border is already the accent.

Moving focus

Focus moves only in response to something the user did, and it always ends somewhere they can see.

EventFocus goes to
Modal opensThe modal’s first focusable element, or its heading
Modal closesThe control that opened it
Drawer or menu opensIts first item
A route changesThe new page’s <h1>, or <main>
A validation error appears on submitThe first invalid field
A row is deletedThe next row, or the region’s heading if it was the last

Modals and drawers trap focus while open and close on Esc. Everything else must not trap: a Tab inside a table, a card, or a tab strip leaves it again.

Deleting a row without moving focus leaves it on a detached node, which drops the keyboard user back at the top of the document — a silent, common, and entirely avoidable regression.

ARIA for the composed patterns

The components page shows tabs, navigation, status, and overlays as markup. This is what each owes assistive technology once it is real.

Tabs

pj-tabs is a tab strip, so it needs the full pattern: role="tablist" on the container, role="tab" with aria-selected on each tab, role="tabpanel" with aria-labelledby on each panel. Arrow keys move between tabs; Tab leaves the strip entirely — only the selected tab is in the tab order.

If arrow-key navigation is not implemented, do not use the tab roles. A tablist that does not respond to arrow keys is worse than a list of links, because it has promised behaviour it does not have.

The current page in a pj-sidebar or pj-navbar is marked aria-current="page". The 600-weight and the accent underline are the visual half of that statement; aria-current is the other half, and neither substitutes for the other.

Status and semantic colour

Every pj-status dot is paired with its text label. That rule appears on the components page as a visual-design rule; it is also the accessibility rule, and it is why the system has no bare-dot variant to reach for.

Live regions announce changes that happen without a user action:

RegionAttribute
Loading regionrole="status" + aria-label
Toast, non-urgent updatearia-live="polite"
Validation summary, failurearia-live="assertive"
Progress barrole="progressbar" with aria-valuenow / min / max

Reserve assertive for things that interrupt correctly. An assertive region that fires on every keystroke makes the page unusable with a screen reader.

Icons

An icon that repeats its label is aria-hidden="true". An icon that is the control carries the accessible name: <button aria-label="Security settings">. See Icons.

Tables

A pj-table uses <th scope="col">, and scope="row" on the identifying column. Sortable headers carry aria-sort="ascending" | "descending" | "none", updated when the sort changes — the arrow glyph alone is not announced. Multi-level headers need scope="colgroup" on the group row.

Motion and preference

The motion rules are an accessibility requirement, not a stylistic one. Everything animated is wrapped in prefers-reduced-motion: no-preference, or neutralised under reduce.

Skeletons stop shimmering under reduce but stay visible — the loading state is information, and removing it would remove the information along with the motion.

What to check before shipping a screen

  1. Tab from the address bar to the end. Focus is visible at every stop and the order matches the visual order.
  2. The skip link is the first stop, and using it lands focus in <main>.
  3. Nothing is reachable only by mouse; nothing is reachable only by keyboard.
  4. Every image, icon button, and form control has an accessible name.
  5. Zoom to 200% and to 400%. Nothing is clipped; nothing scrolls in two directions at once.
  6. Turn colour off — greyscale the screen. Every state is still distinguishable.
  7. Contrast every new pairing against its actual surface, not against white.
Do

Write regions in reading order and place them with CSS. Return focus to the trigger when an overlay closes. Pair every colour signal with text. Label both nav landmarks.

Don't

Use a positive tabindex, reorder content with order or grid-area, remove a focus outline without replacing it, or apply tab roles to a strip that does not handle arrow keys.