Link
Inline navigation. Underlines, visited state, external indicators, and never the words "click here".
Also called Anchor, Hyperlink, Text Link — in this system all of them are Link.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The rollback returned api-gateway to build 4019 after the health check failed. The full trace is attached to deployment 4021, and the postmortem is due on Friday.
Three variants
Inline is underlined and always will be. Standalone drops the underline because its position and weight already mark it. Quiet is for dense metadata where accent colour would be noise.
Inline, inside a sentence: see deployment 4021 for the full trace.
View all deploymentsLink text names the destination
Screen-reader users list every link on a page. "Read more" three times is three identical entries with no way to tell them apart.
External and download
An icon plus visually hidden text, because the icon alone is not announced. And rel="noopener noreferrer" is not optional on target="_blank".
The spec lives in the WCAG 2.2 recommendation (opens in a new tab).
deployment-log-4021.txt(284 KB)Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
The rollback returned api-gateway to build 4019 after the health check failed in eu-west-2 (opens in a new tab).
An accent-coloured run of text with an underline offset clear of the descenders, and an external marker that is announced as well as drawn.
- Colour--ds-accent-text
The text-certified accent step, not the fill. The fill colour is tuned for a white foreground and fails as text on the page surface.
- Underline1px, 3px offset
Offset far enough to clear descenders on g, y and p. A flush underline strikes through them and makes the word harder to read, not easier to see.
- Underline colourAccent border, → text on hover
Slightly lighter at rest so the underline supports the word rather than competing with it, and darkening on hover confirms the target.
- Hit areaThe text box
An inline link is as tall as its line. That is why link-dense paragraphs are hard on touch, and why standalone links get padding.
- Focus ring2px, offset 2px
Offset so the ring does not sit on the underline. A wrapped link gets a ring per line fragment, which is correct and worth expecting.
- External marker12px icon + hidden text
The icon is aria-hidden and paired with "(opens in a new tab)". An icon alone is announced as nothing at all.
- VisitedDimmed accent
Worth having in documentation and search results, where "have I read this?" is a real question. Pointless in an app, where every link is visited.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-accent-text | — | Link text — the text-certified step |
| --ds-accent-border | — | The underline at rest |
| --ds-fg-secondary | — | A quiet link’s text |
| --ds-border-strong | — | A quiet link’s underline |
| --ds-focus-ring | — | Focus outline |
| --ds-fg-disabled | — | An unavailable link — rendered as a span, not an anchor |
Spacing
| Token | Value | Used for |
|---|---|---|
| underline-offset | Clearing descenders |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xs | Focus ring corners |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Underline colour transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Icon | Label gap | Type | Touch target | When to use |
|---|---|---|---|---|---|---|
| Inline | Line height | — | — | Inherits | — | Inside prose. Always underlined — colour alone is not a signal. |
| Standalone | 20px | — | — | 13–15px | 44px with padding | On its own line, usually with a trailing arrow. Underline appears on hover. |
| Quiet | Line height | — | — | 12px | — | Dense metadata rows, where accent colour on every value would be noise. |
| External icon | — | 12px | 4px | — | — | Sized to the surrounding text so it does not disturb the line. |
| Touch target | — | — | — | — | 44px | Standalone links get padding. Inline links inside prose cannot, which is why link-dense paragraphs are hard on a phone. |
The full trace is in deployment 4021.
<svg aria-hidden />
<span class="sr-only"> (opens in a new tab)</span>rel="noopener noreferrer"<div onClick={() => router.push('/x')}>The rollback returned api-gateway to build 4019 after the health check failed.
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Link text owes 4.5:1 against the page like any other text.
- If the link is not underlined, it must reach 3:1 against the surrounding text as well as 4.5:1 against the background. Almost no palette manages both — which is why the underline is the rule.
- The underline itself owes 3:1: it is the thing carrying the signal.
- Hover must change more than the cursor. A colour or decoration change confirms the target for anyone not watching the pointer.
Keyboard
| Tab | Moves to the link. Any element with an href is focusable for free. |
| Enter | Follows the link. Space does not — that is the difference from a button, and it is why the element choice matters. |
| ⌘ / Ctrl + Enter | Opens in a new tab. Only works on a real anchor with an href. |
| Middle-click | Opens in a new tab. Also only works on a real anchor. |
Screen readers
- Users routinely list every link on a page. Each one must make sense on its own, which is what 2.4.9 is asking for.
- Announce a new tab in the link text via visually hidden content. An icon is aria-hidden and conveys nothing.
- For a download, put the type and size in the visible text: "deployment-log.txt (284 KB)" tells everyone what they are about to get.
Focus & touch
- The focus ring is offset so it does not sit on the underline. A link wrapping across lines gets a ring per fragment — that is correct behaviour and should not be "fixed" with a box-decoration hack that clips it.
- Inline links inside prose cannot be padded without breaking the line rhythm, which makes a link-dense paragraph genuinely hard to tap. Prefer fewer, longer link phrases over many short ones. Standalone links should be padded to 44px, and adjacent links need at least 8px between them so a thumb cannot hit both.
| Attribute | Applied to | Notes |
|---|---|---|
| href | The anchor | Without it the element is not focusable and not announced as a link. href="#" with a click handler is the same bug in a different shape. |
| rel="noopener noreferrer" | target="_blank" links | Not optional. Without noopener the opened page can reach back through window.opener. |
| aria-label | A link whose text is not self-describing | Last resort. Rewriting the visible text is better for everyone, including the people who can see it. |
| aria-current="page" | The link to the current view | In navigation, this is what says "you are here". |
| download | A file link | Plus the file type and size in the visible text, so nobody clicks blind on a 40 MB download. |
Example usage
1import { Link } from '@/ui/Display'23<p>4 The full trace is attached to <Link href="/d/4021">deployment 4021</Link>.5</p>67<Link href="/deployments" variant="standalone">8 View all deployments <ArrowRight />9</Link>1011// External: the icon is decoration, the hidden text is the announcement,12// and rel is not optional.13<a href={url} target="_blank" rel="noopener noreferrer">14 WCAG 2.2 recommendation15 <ExternalLink aria-hidden />16 <span className="sr-only"> (opens in a new tab)</span>17</a>1819// The test: does the URL change? Then it is an anchor, and it must survive20// middle-click, ⌘-click and "copy link address".21<a href="/d/4021">View deployment</a> {/* navigates */}22<button onClick={rollback}>Roll back</button> {/* acts */}2324// A disabled link is not a thing. Render a span — an anchor with no href is25// still announced as a link and still focusable in some browsers.26{canView ? (27 <Link href={href}>{label}</Link>28) : (29 <span aria-disabled className="text-[var(--ds-fg-disabled)]">{label}</span>30)}3132// In a router, keep the href real so the browser affordances survive.33<a href={to} onClick={(e) => {34 if (e.metaKey || e.ctrlKey || e.button !== 0) return // let the browser win35 e.preventDefault()36 navigate(to)37}}>{children}</a>Framework-free HTML
<!-- Inline: underlined, because colour alone is not a signal. -->
<p>
The rollback returned api-gateway to
<a href="/builds/4019" class="ds-link">build 4019</a>.
</p>
<!-- Standalone: position and weight mark it, so the underline waits for hover. -->
<a href="/deployments" class="ds-link ds-link--standalone">
View all deployments
<svg aria-hidden="true">…</svg>
</a>
<!-- External: icon hidden, announcement in text, rel not optional. -->
<a href="https://www.w3.org/TR/WCAG22/" target="_blank" rel="noopener noreferrer">
WCAG 2.2 recommendation
<svg aria-hidden="true">…</svg>
<span class="sr-only"> (opens in a new tab)</span>
</a>
<!-- Download: type and size visible, so nobody clicks blind. -->
<a href="/logs/4021.txt" download>
deployment-log-4021.txt <span>(284 KB)</span>
</a>
<!-- Navigation: aria-current is what says "you are here". -->
<a href="/settings" aria-current="page">Settings</a>CSS
.ds-link {
color: var(--ds-accent-text); /* the text-certified step, not the fill */
text-decoration: underline;
text-decoration-thickness: 1px;
/* Clears descenders on g, y and p. A flush underline strikes through them
and makes the word harder to read, not easier to see. */
text-underline-offset: 3px;
/* Lighter at rest so it supports the word rather than competing with it. */
text-decoration-color: var(--ds-accent-border);
border-radius: var(--radius-xs);
transition: text-decoration-color 120ms;
}
.ds-link:hover { text-decoration-color: var(--ds-accent-text); }
/* Offset so the ring does not sit on the underline. */
.ds-link:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
/* Position and weight already mark it, so the underline waits for hover. */
.ds-link--standalone {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
text-decoration: none;
}
.ds-link--standalone:hover { text-decoration: underline; text-underline-offset: 4px; }
/* Worth having in docs and search results; pointless in an app where every
link is visited within a week. */
.ds-prose .ds-link:visited { color: var(--p-brand-300); }
/* Fewer, longer link phrases beat many short ones on touch — an inline link
cannot be padded without breaking the line rhythm. */
@media (pointer: coarse) {
.ds-link--standalone { padding-block: 10px; }
}
@media (forced-colors: active) {
.ds-link { text-decoration: underline; } /* colour is overridden here */
}Component API
Link
| Prop | Type | Default | Description |
|---|---|---|---|
| href* | string | — | Real, always. A router link that removes the href loses middle-click, ⌘-click and "copy link address". |
| variant | 'inline' | 'standalone' | 'quiet' | 'inline' | Inline is underlined at rest; standalone underlines on hover; quiet is for dense metadata. |
| external | boolean | false | Adds the icon, the hidden announcement, and rel="noopener noreferrer". |
| download | boolean | string | — | Pair it with the file type and size in the visible text. |
| aria-current | 'page' | 'step' | 'location' | — | On the link to the current view. This is what says "you are here". |
Professional tips
- Keep link text to a meaningful phrase rather than a whole sentence. A three-line link is a large blue block that is hard to read and awkward to tap.
- Do not link the same phrase to two different destinations on one page. It reads as a duplicate in a link list and as a mistake to everyone else.
- For file downloads, put the type and size in the visible text. Nobody wants to discover a 40 MB PDF after tapping on a phone.
- Visited styling belongs in documentation, search results and long reference content. In an application it is noise, because everything is visited within a week.
- In a router, keep the real href and let ⌘-click and middle-click fall through to the browser. That is three lines of guard and it removes a whole class of complaint.
Performance
- Prefetch on hover or on viewport entry for likely destinations, with a short delay so a pointer crossing the page does not fetch everything.
- Do not attach a listener per link. One delegated handler on the container scales to a page of hundreds.
- Use native anchors so the browser can apply its own optimisations — speculative loading rules only work on real links.
- Avoid animating text-decoration; it forces a repaint of the text run. Transition the decoration colour instead.
Common mistakes
- A div or button used to navigate, losing every browser affordance.
- Removing the underline from inline links, failing 1.4.1 in most palettes.
- target="_blank" without rel="noopener noreferrer".
- "Click here" and "read more", which are indistinguishable in a link list.
- Opening new tabs by default, taking the back button away.
- An external icon with no announcement, conveying nothing to a screen reader.
- An anchor with no href for a "disabled" link, which is still announced as a link.
- A flush underline that strikes through descenders.
Real-world recommendations
- Removing underlines is the most common accessibility regression in redesigns, and it is always argued for on aesthetic grounds. The underline is doing real work.
- Users open their own new tabs. Products that force target="_blank" get complaints about the back button, not gratitude for the tab.
- Link-purpose failures are the most frequent finding in any audit of a content site, and the fix is nearly always editorial rather than technical.
- In single-page apps, the router link is where browser behaviour quietly disappears. Test middle-click and ⌘-click on a real link before shipping the abstraction.