Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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 deployments
api-gateway·eu-west-2·4021ab9

Link 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".

Link or button

The test is whether the URL changes. If it does, it is a link — and it must survive middle-click, ⌘-click and "copy link address".

NavigatesLink
View deployment
ActsButton

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.

deployment 4021
Hover
deployment 4021
Focus
deployment 4019
Visited
Standalone
Download
deployment 4021
Disabled

Anatomy

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
underline-offsetClearing descenders

Radius

TokenValueUsed for
--radius-xsFocus ring corners

Motion

TokenValueUsed for
--duration-fastUnderline colour transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightIconLabel gapTypeTouch targetWhen to use
InlineLine height——Inherits—Inside prose. Always underlined — colour alone is not a signal.
Standalone20px——13–15px44px with paddingOn its own line, usually with a trailing arrow. Underline appears on hover.
QuietLine height——12px—Dense metadata rows, where accent colour on every value would be noise.
External icon—12px4px——Sized to the surrounding text so it does not disturb the line.
Touch target————44pxStandalone links get padding. Inline links inside prose cannot, which is why link-dense paragraphs are hard on a phone.

Do

The full trace is in deployment 4021.

Underline inline linksColour alone fails for anyone who cannot distinguish it, and almost no accent reaches 3:1 against body text. The underline survives greyscale and low vision.
Read the rollback postmortemnot “read more”
Name the destination in the textScreen-reader users list every link on a page. "Read more" three times is three identical entries with no way to tell them apart.
<svg aria-hidden />
<span class="sr-only"> (opens in a new tab)</span>
Announce a new tab, do not just draw itThe icon is aria-hidden and conveys nothing. Visually hidden text is what tells a screen-reader user the context is about to change.
rel="noopener noreferrer"
Always pair target="_blank" with relWithout noopener, the opened page gets a handle on yours through window.opener. noreferrer also stops the referrer leaking.

Don't

<div onClick={() => router.push('/x')}>
Do not use a div or a button to navigateIt loses middle-click, ⌘-click, "copy link address", browser history and the status bar — and it is invisible to assistive tech.
Do not open new tabs by defaultIt takes the back button away from the user. They can open a new tab themselves; they cannot easily undo one you opened for them.

The rollback returned api-gateway to build 4019 after the health check failed.

Do not remove the underline from inline linksColour alone fails 1.4.1 unless it also reaches 3:1 against the surrounding text, which almost no palette does. In a dense paragraph the links become invisible.
Do not style a link as a filled button unless it is the primary actionA page of link-buttons has no hierarchy, and users cannot tell which one is the recommendation. Inline and standalone links exist for a reason.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.1Use of ColorA2.4.4Link Purpose (In Context)A2.4.9Link Purpose (Link Only)AAA2.5.8Target Size (Minimum)AA3.2.5Change on RequestAAA

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

TabMoves to the link. Any element with an href is focusable for free.
EnterFollows the link. Space does not — that is the difference from a button, and it is why the element choice matters.
⌘ / Ctrl + EnterOpens in a new tab. Only works on a real anchor with an href.
Middle-clickOpens 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.
AttributeApplied toNotes
hrefThe anchorWithout 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" linksNot optional. Without noopener the opened page can reach back through window.opener.
aria-labelA link whose text is not self-describingLast resort. Rewriting the visible text is better for everyone, including the people who can see it.
aria-current="page"The link to the current viewIn navigation, this is what says "you are here".
downloadA file linkPlus the file type and size in the visible text, so nobody clicks blind on a 40 MB download.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
externalbooleanfalseAdds the icon, the hidden announcement, and rel="noopener noreferrer".
downloadboolean | 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".

Notes

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.