Accessibility pattern · Navigation
Vertical tabs
A settings-style rail: one tab stop, the Up and Down arrows inside it, and aria-orientation="vertical" so assistive technology knows which arrows to offer. When the space is narrow the rail becomes a scrolling row, and the script switches the orientation, and the arrow keys, to match.
- WCAG criteria
- 6
- Keyboard rules
- 4
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
Settings
Account
How you appear in the shop, and where your orders go.
- Name
- Asha Rao
- Member since
- March 2021
- Delivery address
- Koregaon Park, Pune
- Language
- English (India)
Notifications
Choose what we send you. Security messages always come through.
Privacy
Decide what other shoppers see and what we use to suggest things.
Appearance
How the shop looks on this device.
Billing
Your membership renews on its own; you can cancel at any time.
- Plan
- Nila Plus, yearly
- Next payment
- ₹1,499 on 12 March
- Pay with
- Card ending 4242
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab | Moves into the tab list, onto the selected tab; the next Tab moves into its panel. |
| Arrow Down or Arrow Up | Moves to the next or previous tab, wrapping at the ends, and shows its panel. |
| Home or End | Moves to the first or last tab. |
| Arrow Right or Arrow Left | Do the same as Down and Up when the rail has become a row in a narrow space. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Tab reaches the tab list | Settings, tab list. Account, tab, selected, 1 of 5 |
| Arrow Down moves to the next tab | Notifications, tab, selected, 2 of 5 |
| Tab moves into the panel | Notifications, tab panel |
| A setting in the panel is turned off | Price drops, checkbox, not checked |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-vertical-tabs" data-ap-vertical-tabs>
<div class="ap-vertical-tabs__frame">
<div class="ap-vertical-tabs__rail">
<h3 class="ap-vertical-tabs__label" id="vtabs-label">Settings</h3>
<div class="ap-vertical-tabs__list" role="tablist" aria-labelledby="vtabs-label" aria-orientation="vertical">
<button type="button" class="ap-vertical-tabs__tab" role="tab" id="vtabs-tab-account" aria-selected="true" aria-controls="vtabs-panel-account">
<svg class="ap-vertical-tabs__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
<span>Account</span>
</button>
<button type="button" class="ap-vertical-tabs__tab" role="tab" id="vtabs-tab-notifications" aria-selected="false" aria-controls="vtabs-panel-notifications" tabindex="-1">
<svg class="ap-vertical-tabs__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4Z"/><path d="M10 21h4"/></svg>
<span>Notifications</span>
</button>
<button type="button" class="ap-vertical-tabs__tab" role="tab" id="vtabs-tab-privacy" aria-selected="false" aria-controls="vtabs-panel-privacy" tabindex="-1">
<svg class="ap-vertical-tabs__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3 5 6v5c0 5 3 8.5 7 10 4-1.5 7-5 7-10V6Z"/><path d="m9 12 2 2 4-4"/></svg>
<span>Privacy</span>
</button>
<button type="button" class="ap-vertical-tabs__tab" role="tab" id="vtabs-tab-appearance" aria-selected="false" aria-controls="vtabs-panel-appearance" tabindex="-1">
<svg class="ap-vertical-tabs__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<span>Appearance</span>
</button>
<button type="button" class="ap-vertical-tabs__tab" role="tab" id="vtabs-tab-billing" aria-selected="false" aria-controls="vtabs-panel-billing" tabindex="-1">
<svg class="ap-vertical-tabs__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18M7 15h4"/></svg>
<span>Billing</span>
</button>
</div>
</div>
<div class="ap-vertical-tabs__panel" role="tabpanel" id="vtabs-panel-account" aria-labelledby="vtabs-tab-account" tabindex="0">
<h4 class="ap-vertical-tabs__name">Account</h4>
<p class="ap-vertical-tabs__lead">How you appear in the shop, and where your orders go.</p>
<dl class="ap-vertical-tabs__facts">
<div><dt>Name</dt><dd>Asha Rao</dd></div>
<div><dt>Member since</dt><dd>March 2021</dd></div>
<div><dt>Delivery address</dt><dd>Koregaon Park, Pune</dd></div>
<div><dt>Language</dt><dd>English (India)</dd></div>
</dl>
</div>
<div class="ap-vertical-tabs__panel" role="tabpanel" id="vtabs-panel-notifications" aria-labelledby="vtabs-tab-notifications" tabindex="0" hidden>
<h4 class="ap-vertical-tabs__name">Notifications</h4>
<p class="ap-vertical-tabs__lead">Choose what we send you. Security messages always come through.</p>
<div class="ap-vertical-tabs__options">
<div class="ap-vertical-tabs__option">
<input type="checkbox" id="vtabs-orders" aria-describedby="vtabs-orders-hint" checked />
<label for="vtabs-orders">Order updates</label>
<p class="ap-vertical-tabs__hint" id="vtabs-orders-hint">Dispatch, delivery and returns.</p>
</div>
<div class="ap-vertical-tabs__option">
<input type="checkbox" id="vtabs-prices" aria-describedby="vtabs-prices-hint" checked />
<label for="vtabs-prices">Price drops</label>
<p class="ap-vertical-tabs__hint" id="vtabs-prices-hint">When something you saved costs less.</p>
</div>
<div class="ap-vertical-tabs__option">
<input type="checkbox" id="vtabs-news" aria-describedby="vtabs-news-hint" />
<label for="vtabs-news">New arrivals</label>
<p class="ap-vertical-tabs__hint" id="vtabs-news-hint">One email a week, on Fridays.</p>
</div>
</div>
</div>
<div class="ap-vertical-tabs__panel" role="tabpanel" id="vtabs-panel-privacy" aria-labelledby="vtabs-tab-privacy" tabindex="0" hidden>
<h4 class="ap-vertical-tabs__name">Privacy</h4>
<p class="ap-vertical-tabs__lead">Decide what other shoppers see and what we use to suggest things.</p>
<div class="ap-vertical-tabs__options">
<div class="ap-vertical-tabs__option">
<input type="checkbox" id="vtabs-reviews" aria-describedby="vtabs-reviews-hint" checked />
<label for="vtabs-reviews">Show my name on reviews</label>
<p class="ap-vertical-tabs__hint" id="vtabs-reviews-hint">Otherwise reviews show your initials.</p>
</div>
<div class="ap-vertical-tabs__option">
<input type="checkbox" id="vtabs-history" aria-describedby="vtabs-history-hint" />
<label for="vtabs-history">Use my orders for suggestions</label>
<p class="ap-vertical-tabs__hint" id="vtabs-history-hint">Recommendations based on what you bought.</p>
</div>
</div>
</div>
<div class="ap-vertical-tabs__panel" role="tabpanel" id="vtabs-panel-appearance" aria-labelledby="vtabs-tab-appearance" tabindex="0" hidden>
<h4 class="ap-vertical-tabs__name">Appearance</h4>
<p class="ap-vertical-tabs__lead">How the shop looks on this device.</p>
<fieldset class="ap-vertical-tabs__group">
<legend>Theme</legend>
<label class="ap-vertical-tabs__choice"><input type="radio" name="vtabs-theme" value="system" checked /> Match the system</label>
<label class="ap-vertical-tabs__choice"><input type="radio" name="vtabs-theme" value="light" /> Light</label>
<label class="ap-vertical-tabs__choice"><input type="radio" name="vtabs-theme" value="dark" /> Dark</label>
</fieldset>
</div>
<div class="ap-vertical-tabs__panel" role="tabpanel" id="vtabs-panel-billing" aria-labelledby="vtabs-tab-billing" tabindex="0" hidden>
<h4 class="ap-vertical-tabs__name">Billing</h4>
<p class="ap-vertical-tabs__lead">Your membership renews on its own; you can cancel at any time.</p>
<dl class="ap-vertical-tabs__facts">
<div><dt>Plan</dt><dd>Nila Plus, yearly</dd></div>
<div><dt>Next payment</dt><dd>₹1,499 on 12 March</dd></div>
<div><dt>Pay with</dt><dd>Card ending 4242</dd></div>
</dl>
</div>
</div>
</div>
/* Vertical tabs. Uses the --ap-* design tokens. */
.ap-vertical-tabs {
width: min(100%, 720px);
margin-inline: auto;
container-type: inline-size;
}
.ap-vertical-tabs__frame {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
min-height: 360px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-md);
}
/* The rail: a label, then the tabs top to bottom. */
.ap-vertical-tabs__rail {
padding: 16px 10px;
border-right: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg) 0 0 var(--ap-radius-lg);
background: var(--ap-surface-2);
}
.ap-vertical-tabs__label {
margin: 0 0 10px;
padding: 0 10px;
color: var(--ap-text-3);
font-size: .8125rem;
font-weight: 650;
letter-spacing: .04em;
text-transform: uppercase;
}
.ap-vertical-tabs__list {
display: flex;
flex-direction: column;
gap: 2px;
}
.ap-vertical-tabs__tab {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: var(--ap-radius-sm);
background: transparent;
color: var(--ap-text-2);
font: inherit;
font-weight: 500;
text-align: start;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), color var(--ap-duration) var(--ap-ease);
}
.ap-vertical-tabs__tab:hover {
background: var(--ap-surface-3);
color: var(--ap-text);
}
.ap-vertical-tabs__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Selected: raised, heavier, and marked with a bar on the inner edge. */
.ap-vertical-tabs__tab[aria-selected="true"] {
border-color: var(--ap-border);
background: var(--ap-surface);
color: var(--ap-text);
font-weight: 650;
box-shadow: var(--ap-shadow-sm);
}
.ap-vertical-tabs__tab[aria-selected="true"] .ap-vertical-tabs__icon {
color: var(--ap-accent-text);
}
.ap-vertical-tabs__tab[aria-selected="true"]::before {
content: "";
position: absolute;
top: 10px;
bottom: 10px;
left: -1px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--ap-accent);
}
.ap-vertical-tabs__tab:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
}
/* A panel: a heading, a line of context, then the settings. */
.ap-vertical-tabs__panel {
padding: 22px 26px 26px;
border-radius: 0 var(--ap-radius-lg) var(--ap-radius-lg) 0;
}
.ap-vertical-tabs__panel:not([hidden]) {
animation: ap-vertical-tabs-in 180ms var(--ap-ease);
}
.ap-vertical-tabs__panel:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: -4px;
}
.ap-vertical-tabs__name {
margin: 0;
font-size: 1.25rem;
font-weight: 650;
line-height: 1.3;
}
.ap-vertical-tabs__lead {
margin: 4px 0 18px;
color: var(--ap-text-2);
line-height: 1.55;
}
.ap-vertical-tabs__facts {
display: grid;
margin: 0;
}
.ap-vertical-tabs__facts div {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px 16px;
padding: 11px 0;
border-top: 1px solid var(--ap-border);
}
.ap-vertical-tabs__facts dt {
color: var(--ap-text-3);
}
.ap-vertical-tabs__facts dd {
margin: 0;
color: var(--ap-text);
font-weight: 600;
text-align: end;
}
/* A setting: the box, its label, and a hint under the label. */
.ap-vertical-tabs__options {
display: grid;
}
.ap-vertical-tabs__option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
column-gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--ap-border);
}
.ap-vertical-tabs__option input {
width: 18px;
height: 18px;
margin: 2px 0 0;
accent-color: var(--ap-accent);
cursor: pointer;
}
.ap-vertical-tabs__option label {
color: var(--ap-text);
font-weight: 600;
cursor: pointer;
}
.ap-vertical-tabs__hint {
grid-column: 2;
margin: 2px 0 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-vertical-tabs__group {
display: grid;
gap: 2px;
margin: 0;
padding: 12px 0 0;
border: 0;
border-top: 1px solid var(--ap-border);
}
.ap-vertical-tabs__group legend {
float: left;
width: 100%;
margin-bottom: 6px;
padding: 0;
color: var(--ap-text);
font-weight: 600;
}
.ap-vertical-tabs__choice {
display: flex;
align-items: center;
gap: 12px;
min-height: 40px;
color: var(--ap-text-2);
cursor: pointer;
}
.ap-vertical-tabs__choice input {
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--ap-accent);
cursor: pointer;
}
.ap-vertical-tabs :is(.ap-vertical-tabs__option, .ap-vertical-tabs__choice) input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
/* Narrow: the rail becomes a row that scrolls sideways on its own,
and the bar moves to the bottom edge. */
@container (max-width: 540px) {
.ap-vertical-tabs__frame {
grid-template-columns: minmax(0, 1fr);
min-height: 0;
}
.ap-vertical-tabs__rail {
padding: 12px 0 0;
border-right: 0;
border-bottom: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg) var(--ap-radius-lg) 0 0;
}
.ap-vertical-tabs__label {
padding: 0 18px;
margin-bottom: 6px;
}
.ap-vertical-tabs__list {
flex-direction: row;
gap: 4px;
padding: 4px 14px 10px;
overflow-x: auto;
scroll-padding-inline: 14px;
scrollbar-width: thin;
}
/* Room after the last tab, so its focus ring is never cut off at the end. */
.ap-vertical-tabs__list::after {
content: "";
flex: 0 0 4px;
}
.ap-vertical-tabs__tab {
flex: 0 0 auto;
gap: 8px;
min-height: 40px;
padding: 0 12px;
}
.ap-vertical-tabs__tab[aria-selected="true"]::before {
top: auto;
right: 12px;
bottom: -1px;
left: 12px;
width: auto;
height: 3px;
border-radius: 3px 3px 0 0;
}
.ap-vertical-tabs__panel {
padding: 18px 18px 22px;
border-radius: 0 0 var(--ap-radius-lg) var(--ap-radius-lg);
}
}
@keyframes ap-vertical-tabs-in {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.ap-vertical-tabs__tab { transition: none; }
.ap-vertical-tabs__panel:not([hidden]) { animation: none; }
}
/**
* Vertical tabs: a tab list in a side rail, with one tab stop and the Up
* and Down arrow keys inside it.
*
* Markup: [data-ap-vertical-tabs] holding [role=tablist][aria-orientation]
* with button[role=tab][aria-controls] for each div[role=tabpanel]. When the
* CSS lays the list out as a row (a container query on narrow space), the
* script sets aria-orientation="horizontal" and the Left and Right arrows
* take over from Up and Down.
*/
export function init(root) {
const list = root.querySelector("[role=tablist]");
const tabs = [...list.querySelectorAll("[role=tab]")];
const panelOf = (tab) => document.getElementById(tab.getAttribute("aria-controls"));
function select(tab) {
for (const t of tabs) {
const on = t === tab;
t.setAttribute("aria-selected", String(on));
t.tabIndex = on ? 0 : -1;
panelOf(t).hidden = !on;
}
}
// The orientation follows the layout the CSS chose.
function syncOrientation() {
const row = getComputedStyle(list).flexDirection.startsWith("row");
list.setAttribute("aria-orientation", row ? "horizontal" : "vertical");
}
function onClick(event) {
const tab = event.target.closest("[role=tab]");
if (tab && tabs.includes(tab)) select(tab);
}
function onKeydown(event) {
const at = tabs.indexOf(document.activeElement);
if (at === -1) return;
const vertical = list.getAttribute("aria-orientation") !== "horizontal";
const nextKey = vertical ? "ArrowDown" : "ArrowRight";
const prevKey = vertical ? "ArrowUp" : "ArrowLeft";
let next = null;
if (event.key === nextKey) next = tabs[(at + 1) % tabs.length];
else if (event.key === prevKey) next = tabs[(at - 1 + tabs.length) % tabs.length];
else if (event.key === "Home") next = tabs[0];
else if (event.key === "End") next = tabs[tabs.length - 1];
if (!next) return;
event.preventDefault();
select(next);
next.focus();
// In the scrolling row, keep the whole tab and its focus ring in view
// (scrollIntoView honours the list's scroll-padding; focus alone does not).
next.scrollIntoView({ block: "nearest", inline: "nearest" });
}
const resize = new ResizeObserver(syncOrientation);
resize.observe(root);
list.addEventListener("click", onClick);
list.addEventListener("keydown", onKeydown);
select(tabs.find((t) => t.getAttribute("aria-selected") === "true") || tabs[0]);
syncOrientation();
return () => {
resize.disconnect();
list.removeEventListener("click", onClick);
list.removeEventListener("keydown", onKeydown);
};
}
for (const root of document.querySelectorAll("[data-ap-vertical-tabs]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
Roles tie the tabs to their panels, and aria-orientation says the list runs top to bottom, so the structure is exposed, not just drawn.
-
1.4.1 Use of Color
Level A
The selected tab is raised, set in heavier text and marked with a bar, so it does not depend on color.
-
2.1.1 Keyboard
Level A
The arrow keys, Home and End move between tabs, and every setting inside the panels is a native field.
-
2.4.3 Focus Order
Level A
Only the selected tab is in the Tab order, so the next Tab goes straight into its panel.
-
2.4.7 Focus Visible
Level AA
The focused tab shows a ring that clears 3:1 against the rail.
-
4.1.2 Name, Role, Value
Level A
Each tab has role=tab and aria-selected, and each panel is labelled by its tab.
Usage
When to use it
Use it
- Settings and preferences with five or more sections, where a side rail has room for longer labels.
- Labels that are too long, or too many, to sit in a row of tabs.
Use something else
- Moving between pages: use a sidebar navigation of links marked with aria-current.
- Two or three sections: a row of tabs or a segmented control is lighter.
Common failures
How it usually goes wrong
aria-orientation left out
Without it, a tab list is taken to be horizontal: screen readers suggest Left and Right while the rail on screen invites Up and Down.
Arrow keys that do not follow the layout
A rail that becomes a row on a phone but still answers only to Up and Down confuses everyone. Here the orientation, and the keys, follow the layout.
Links dressed as tabs
If each item loads a new page, it is navigation: links in a nav landmark with aria-current, not a tab list.
Icons without words
A rail of icons alone saves space and leaves people guessing. Each tab here has its word; the icon is decoration.
Selection shown only by color
A tinted background is easy to miss. The selected tab here is raised, bolder and marked with a bar.
Notes
Building it
- The script reads the rail's layout when the component is resized and sets aria-orientation to vertical or horizontal; the arrow keys follow that value.
- Each panel starts with a heading and plain text, so every panel has tabindex=0 and the next Tab reaches it.
- Settings here apply as they change. If yours need a Save button, put it inside each panel so it is clear what it saves.
- The icons are aria-hidden SVG drawn with currentColor, so they follow the text color in both themes.
Sources: WAI-ARIA Authoring Practices: Tabs
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction