What a screen reader actually hears
The same countdown three ways, in any language — and a running count of how often it would interrupt someone, against the every-second alternative.
<div class="demo demo--wide">
<div class="stage"><div id="face"></div></div>
<div class="demo-row">
<div class="demo-seg" id="variant" role="group" aria-label="Shape">
<button type="button" data-variant="boxes" aria-pressed="true">boxes</button>
<button type="button" data-variant="clock">clock</button>
<button type="button" data-variant="words">words</button>
</div>
<button class="demo-btn demo-btn--ghost" id="soon">Restart at two minutes</button>
</div>
<div class="demo-row">
<div class="demo-seg" id="locale" role="group" aria-label="Language">
<button type="button" data-locale="" aria-pressed="true">English</button>
<button type="button" data-locale="ar-EG">العربية</button>
<button type="button" data-locale="ru">Русский</button>
<button type="button" data-locale="ur">اردو</button>
<button type="button" data-locale="de">Deutsch</button>
</div>
</div>
<dl class="demo-kv" id="out"></dl>
<p class="demo-hint">The digits are <code>aria-hidden</code> and a separate polite region speaks only at
milestones. Wire the digits straight into <code>aria-live</code>, which is what every alternative
leaves you to do, and the second number is what someone hears instead.</p>
</div>
import { createCountdownWidget } from 'https://www.devix.pk/cdn/oss/countdown@1.0.0/index.js';
const out = document.getElementById('out');
const host = document.getElementById('face');
let deadline = Date.now() + 2 * 60 * 1000 + 500;
let shape = 'boxes';
let lang = '';
let spoken = 0;
let ticks = 0;
let widget;
// The instance is passed in, because the first tick happens while the const
// below is still being assigned.
const show = (instance = widget) => {
const parts = instance.read();
out.innerHTML = `
<div><dt>remaining</dt><dd>${parts.minutes}m ${parts.seconds}s</dd></div>
<div><dt>redrawn</dt><dd>${ticks} times</dd></div>
<div><dt>said to a screen reader</dt><dd><strong>${spoken}</strong></dd></div>
<div><dt>an aria-live on the digits</dt><dd>${ticks} times</dd></div>`;
};
function build() {
widget?.destroy();
widget = createCountdownWidget(host, {
deadline,
variant: shape,
locale: lang || undefined,
onTick: (parts, instance) => {
ticks += 1;
show(instance);
},
onComplete: () => host.classList.add('is-done'),
});
// Count what the live region actually says rather than claiming a number.
const live = host.querySelector('[role="status"]');
new MutationObserver(() => {
if (live.textContent.trim()) spoken += 1;
}).observe(live, { childList: true, characterData: true, subtree: true });
// The first announcement happened during construction, before the observer.
if (live.textContent.trim()) spoken += 1;
show();
}
const segment = (id, attribute, apply) => {
const group = document.getElementById(id);
group.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
for (const other of group.querySelectorAll('button')) other.setAttribute('aria-pressed', String(other === button));
apply(button.dataset[attribute]);
build();
});
};
segment('variant', 'variant', (value) => { shape = value; });
segment('locale', 'locale', (value) => { lang = value; });
document.getElementById('soon').addEventListener('click', () => {
deadline = Date.now() + 2 * 60 * 1000 + 500;
spoken = 0;
ticks = 0;
host.classList.remove('is-done');
widget.setDeadline(deadline);
show();
});
build();
@import url('https://www.devix.pk/cdn/oss/countdown@1.0.0/countdown.min.css');
.stage {
display: flex;
justify-content: center;
padding: 18px 12px;
border: 1px solid var(--demo-line);
border-radius: 12px;
background: var(--demo-surface);
}
#face { --dxn-value-size: 32px; --dxn-radius: 12px; transition: opacity .3s; }
#face.is-done { opacity: .5; }
.demo-kv dd { font-variant-numeric: tabular-nums; }