Accessible Live Log Viewers
Permalink to "Accessible Live Log Viewers"A live log viewer is a panel that streams lines of text as a process runs: CI build output, a deployment, an import job, an audit trail. It seems a natural fit for role="log" — the ARIA role literally named for it — but a naive implementation has every line announced, auto-scrolls the panel so keyboard users cannot read what has passed, and grows the DOM without limit until the page stalls.
This page builds a viewer that is quiet by default, loud for errors, readable by keyboard at any point in the stream, and bounded in size. It belongs to real-time data stream announcements.
Spec reference
Permalink to "Spec reference"role="log" (ARIA 1.2) is a live region “where new information is added in meaningful order and old information may disappear”. Its implicit properties are aria-live="polite", aria-atomic="false" and aria-relevant="additions": appended lines are announced one by one, and nothing else. The role needs an accessible name.
Because the implicit politeness is polite, a busy log speaks continuously. For high-volume logs, set aria-live="off" on the log to silence line-by-line announcements, and route selected events (errors, completion) through the page’s status and alert regions instead. The log keeps its role — it is still a log — but no longer floods speech.
Criteria: SC 4.1.3 Status Messages (errors and completion), SC 2.2.2 Pause, Stop, Hide (auto-updating, auto-scrolling content must be pausable), SC 2.1.1 Keyboard (history must be readable by keyboard), SC 1.4.1 Use of Color (severity not by colour alone).
When to announce lines — and when not to
Permalink to "When to announce lines — and when not to"Announce individual lines only for low-volume logs where every line matters: a deployment with a dozen steps, an approval audit trail. The role="log" defaults then work as intended.
For high-volume logs — build output, verbose job logs — silence the line stream and announce milestones: errors, warnings (optionally), step changes and completion. “Build failed: 1 of 212 tests failed” is what the user needs; the 4,000 lines before it are for reading on demand.
The misapplication to name is auto-scrolling the log by moving focus to the newest line. It yanks keyboard users away from whatever they were reading every time a line arrives.
Annotated code example
Permalink to "Annotated code example"<section aria-labelledby="log-h" class="log-viewer">
<h2 id="log-h">Build #4812 output</h2>
<div class="log-controls" role="group" aria-label="Log controls">
<!-- SC 2.2.2: auto-scroll is a user choice -->
<button type="button" id="follow" aria-pressed="true">Follow new lines</button>
<label for="level">Show</label>
<select id="level"><option>All</option><option>Warnings and errors</option><option>Errors</option></select>
<button type="button" id="prev-err">Previous error</button>
<button type="button" id="next-err">Next error</button>
</div>
<!-- role=log for structure; aria-live off for high volume -->
<ol role="log" aria-label="Build output" aria-live="off" id="log" tabindex="0"></ol>
</section>
const MAX_LINES = 2000; // bound the DOM (and the accessibility tree)
const log = document.getElementById('log');
let follow = true;
function appendLine({ time, level, text }) {
const li = document.createElement('li');
li.dataset.level = level;
li.tabIndex = -1; // focusable for error navigation
// SC 1.4.1: level as text, styled by data attribute
li.innerHTML = `<time>${time}</time> <span class="lvl">${level}</span> <span class="msg"></span>`;
li.querySelector('.msg').textContent = text;
log.append(li);
if (log.children.length > MAX_LINES) log.firstElementChild.remove();
// Scroll the container, never move focus (SC 2.4.3)
if (follow) log.scrollTop = log.scrollHeight;
// SC 4.1.3: milestones only
if (level === 'ERROR') alertRegion(`Error: ${text}`);
}
function onComplete({ ok, summary }) {
status(`Build ${ok ? 'passed' : 'failed'}. ${summary}`); // e.g. "1 of 212 tests failed."
}
// Scrolling up to read stops following automatically; the toggle reflects it
log.addEventListener('scroll', () => {
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 8;
if (!atBottom && follow) setFollow(false);
});
function setFollow(on) { follow = on; document.getElementById('follow').setAttribute('aria-pressed', String(on)); }
// Error navigation: focus the line so screen readers read it
document.getElementById('next-err').addEventListener('click', () => {
const errs = [...log.querySelectorAll('li[data-level="ERROR"]')];
const cur = errs.findIndex((e) => e === document.activeElement);
(errs[cur + 1] ?? errs[0])?.focus();
});
Stopping follow mode when the user scrolls up is the behaviour sighted users know from terminals and CI tools. Doing it automatically — and reflecting it in the toggle’s pressed state — means keyboard users who move back through history are not dragged to the bottom by the next line.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected behaviour | Announcement |
|---|---|---|
| Line appended (INFO) | Line added; container scrolls if following | None (aria-live="off") |
| Line appended (ERROR) | Same | Assertive: “Error: Test “sort” failed” |
| Build finishes | — | “Build failed. 1 of 212 tests failed.” |
| “Next error” | Focus on the error line | “10:03:05 ERROR Test “sort” failed” |
| Arrow keys in the log (browse mode) | Read line by line | Lines as list items, with position |
| Scroll up in the log | Follow turns off | “Follow new lines, toggle button, not pressed” (on focus) |
Integration context
Permalink to "Integration context"The role’s defaults and when to override them are covered in role status, alert and log compared. For logs where lines should be announced but arrive in bursts, pace them with throttling high-frequency aria-live updates.
The line cap exists for the reasons in measuring accessibility tree cost for large tables: a 50,000-line log in the DOM slows every screen reader on the page. For logs that must be fully navigable in place, virtualize with position metadata as in making TanStack Virtual lists and tables accessible.
Gotchas
Permalink to "Gotchas"Colour-coded levels. Red for errors and yellow for warnings are invisible to many users and to screen readers. Always include the level as text.
ANSI escape codes. Build logs contain colour codes; strip them before rendering, or screen readers read “escape bracket 31 m”.
Removing old lines while focused. If the line cap removes the focused line, move focus to the log container first.
Design system notes
Permalink to "Design system notes"A LogViewer component should default to aria-live="off" for streams, own the follow toggle and its auto-disable, render levels as text, cap lines with a download link, and expose onMilestone hooks that route to the shared announcer. Teams streaming low-volume audit logs can opt into per-line announcements explicitly.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Should a live log viewer use role="log"?
Yes — it gives the region the right structure and defaults. For high-volume logs, set aria-live=“off” on it so lines are not all announced, and announce errors and completion through the page’s status and alert regions.
How should auto-scrolling work in an accessible log?
Scroll the log container to new lines only while “follow” is on, never move keyboard focus, and turn follow off automatically when the user scrolls up to read, reflecting that in the toggle’s pressed state.
How can screen reader users find errors in a long log?
Provide Next error and Previous error controls that move focus to error lines, and a severity filter. Write the severity as text in each line so it is read with the message.
How many lines should be kept in the page?
A bounded number — a few thousand at most — so the accessibility tree stays manageable. Offer the complete log as a download or through a virtualized view.
Related
Permalink to "Related"- role status, alert and log — the log role and its defaults
- Throttling aria-live updates — pacing announcements
- Measuring accessibility tree cost — why the line cap matters