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.
Skip links
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:
| Region | Element | Notes |
|---|
| 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.
| Event | Focus goes to |
|---|
| Modal opens | The modal’s first focusable element, or its heading |
| Modal closes | The control that opened it |
| Drawer or menu opens | Its first item |
| A route changes | The new page’s <h1>, or <main> |
| A validation error appears on submit | The first invalid field |
| A row is deleted | The 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.
Navigation
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:
| Region | Attribute |
|---|
| Loading region | role="status" + aria-label |
| Toast, non-urgent update | aria-live="polite" |
| Validation summary, failure | aria-live="assertive" |
| Progress bar | role="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
- Tab from the address bar to the end. Focus is visible at every stop and the
order matches the visual order.
- The skip link is the first stop, and using it lands focus in
<main>. - Nothing is reachable only by mouse; nothing is reachable only by keyboard.
- Every image, icon button, and form control has an accessible name.
- Zoom to 200% and to 400%. Nothing is clipped; nothing scrolls in two
directions at once.
- Turn colour off — greyscale the screen. Every state is still distinguishable.
- 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.