Accessibility
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-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.
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.
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.