A link's words should say where it goes, even read on their own in a screen reader's list of links: a file says its type and size, and a new tab says so before it opens.Links in text are underlined, so they never depend on color, and anything that acts on the page rather than going somewhere is a button.
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.
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 or Shift+Tab
Moves through the links and buttons in reading order, each with a visible focus ring.
Enter
Follows the focused link. The external one opens in a new tab; the demo's own pages do not exist here, so a note says what would happen.
Enter or Space
On a button, does its job on this page: copies the link, or shows the list of link names.
Screen readers
What it announces
Written from the roles, names and states in the markup.
When
Expected announcement
Tab reaches the first link
how we chose the villages, link
Tab reaches the external link
rainfall data from IMD Pune (opens in a new tab), link
Tab reaches the download
Annual report 2025–26, PDF, 2.4 MB, link
Tab reaches a Read more link
Read more about the water committees in Khatav, link
Copy link to this update is pressed
Link copied
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.
The markup
<div class="ap-links" data-ap-links>
<article class="ap-links__card">
<p class="ap-links__kicker">Annual update</p>
<h3 class="ap-links__name">A year of village ponds</h3>
<p class="ap-links__text">This year our volunteers cleared silt from 42 ponds across Satara and Sangli, and the monsoon filled every one. Read <a href="/ponds/how-we-chose/">how we chose the villages</a>, or see this year's <a href="https://www.imdpune.gov.in/" target="_blank" rel="noopener">rainfall data from IMD Pune<svg class="ap-links__ext" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg><span class="ap-links__vh"> (opens in a new tab)</span></a>.</p>
<a class="ap-links__file" href="/reports/annual-report-2025-26.pdf" download>
<span class="ap-links__badge" aria-hidden="true" translate="no">PDF</span>
<span class="ap-links__file-text">
<span class="ap-links__file-name">Annual report 2025–26<span class="ap-links__vh">,</span></span>
<span class="ap-links__file-meta">PDF, 2.4 MB</span>
</span>
<svg class="ap-links__dl" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>
</a>
<h4 class="ap-links__sub">More from the field</h4>
<ul class="ap-links__stories">
<li class="ap-links__story">
<p class="ap-links__story-name">Water committees run by women</p>
<p class="ap-links__story-text">Nine villages in Khatav now look after their own ponds.</p>
<a class="ap-links__more" href="/stories/water-committees/">Read more<span class="ap-links__vh"> about the water committees in Khatav</span><svg class="ap-links__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</li>
<li class="ap-links__story">
<p class="ap-links__story-name">Birds are back at the ponds</p>
<p class="ap-links__story-text">Volunteers counted 61 species in one January weekend.</p>
<a class="ap-links__more" href="/stories/bird-count/">Read more<span class="ap-links__vh"> about the bird count</span><svg class="ap-links__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</li>
</ul>
<h4 class="ap-links__sub">Link or button?</h4>
<div class="ap-links__versus">
<div class="ap-links__case">
<p class="ap-links__case-k">Goes to another page: a link</p>
<a class="ap-links__more" href="/projects/">View all projects<svg class="ap-links__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
</div>
<div class="ap-links__case">
<p class="ap-links__case-k">Does something here: a button</p>
<button type="button" class="ap-btn ap-links__copy"><svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>Copy link to this update</button>
</div>
</div>
<p class="ap-links__status" role="status"></p>
</article>
<div class="ap-links__names">
<button type="button" class="ap-links__reveal" aria-expanded="false" aria-controls="links-list">
<span>Show these links as a screen reader lists them</span>
<svg class="ap-links__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="ap-links__panel" id="links-list" hidden>
<p class="ap-links__panel-k">Each link's name, out of context:</p>
<ol></ol>
</div>
</div>
</div>
/**
* Links that say where they go: a content card whose links carry their
* purpose in their text (visually hidden where needed), plus a button for an
* action on the page and a view of the card's links as a links list shows them.
*
* Markup: [data-ap-links] holding the card's links, a button.ap-links__copy,
* a button.ap-links__reveal[aria-expanded][aria-controls] for the list, and a
* role=status line. The links point at pages this demo does not have, so
* the script keeps them on the page and says so; the external one opens.
*/
export function init(root) {
const status = root.querySelector("[role=status]");
const reveal = root.querySelector(".ap-links__reveal");
const panel = document.getElementById(reveal.getAttribute("aria-controls"));
const names = panel.querySelector("ol");
const card = root.querySelector(".ap-links__card");
// Clear first, so the same message is announced again on a second press.
function say(text) {
status.textContent = "";
requestAnimationFrame(() => { status.textContent = text; });
}
// Roughly what a screen reader's links list shows: each link's text,
// visually hidden parts included, aria-hidden parts left out.
function nameOf(link) {
const copy = link.cloneNode(true);
for (const hidden of copy.querySelectorAll("[aria-hidden='true']")) hidden.remove();
return copy.textContent.replace(/\s+/g, " ").trim();
}
function showNames(open) {
reveal.setAttribute("aria-expanded", String(open));
panel.hidden = !open;
if (open) names.replaceChildren(...[...card.querySelectorAll("a[href]")].map((link) => {
const item = document.createElement("li");
item.textContent = nameOf(link);
return item;
}));
}
async function copyLink() {
try {
await navigator.clipboard.writeText(location.href.split("#")[0]);
say("Link copied");
} catch {
say("Could not copy the link");
}
}
function onClick(event) {
if (event.target.closest(".ap-links__copy")) {
copyLink();
return;
}
if (event.target.closest(".ap-links__reveal")) {
showNames(reveal.getAttribute("aria-expanded") !== "true");
return;
}
const link = event.target.closest("a[href]");
if (!link || link.target === "_blank") return;
// The demo's own pages do not exist here: stay, and say what would happen.
event.preventDefault();
say(link.hasAttribute("download") ? "In a real site this link downloads the PDF." : "In a real site this link opens that page.");
}
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}
for (const root of document.querySelectorAll("[data-ap-links]")) init(root);
Going somewhere is a link and doing something here is a button, so each is announced with the role people expect.
Usage
When to use it
Use it
Any text with links: articles, cards, help pages, emails.
Lists of teasers where every item ends in Read more.
Use something else
Actions on the page, like copying, opening a panel or submitting: use a button.
Opening a new tab for every external link: do it only when leaving the page would lose work.
Common failures
How it usually goes wrong
Click here and Read more
In a list of links, ten "Read more" links are ten identical choices. Visually hidden words make each one say what it is about.
Links shown only by color
Blue text in grey text is hard to spot for many people, and impossible for some. An underline marks a link in any color.
A new tab without warning
The back button stops working and screen reader users may not notice the new tab. The link says it opens one, in words and with an icon.
Downloads that surprise
A link that starts a large download, or opens a PDF on a phone, costs time and data. Saying the type and size lets people choose.
A link that acts like a button
A link with href="#" that copies or opens something is announced as a link, so people expect to go somewhere. Use a button.
aria-label that replaces the visible words
aria-label="Water committees" on a link that shows Read more breaks voice control. Add hidden words after the visible ones instead.
Notes
Building it
Visually hidden text keeps the design short while giving screen reader users the full name; put it after the visible words so the name still starts with what people see.
Browsers let :visited change only colors, for privacy, so the underline does the work of marking a link and the visited color is an extra.
The download attribute asks the browser to save the file rather than open it; the type and size in the link let people decide before they tap.
The list above is built from each link's text, skipping parts marked aria-hidden, which is roughly what a screen reader's links list shows.
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