Link
A semantic anchor for navigation — in prose or out of it. Three variants, five sizes, five intents, three tones, a derived underline, safe external-link handling with a screen-reader warning, an opt-in visited state, icon slots, and router integration.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Link } from "@tessinaui/ui";<Link href="/docs">Read the documentation</Link>
<Link variant="standalone" trailingIcon={<ArrowRight />} href="/pricing">
See pricing
</Link>
<Link external href="https://tessinaui.com">Tessina UI</Link>When to use it
A Link navigates. It has an href, it goes somewhere, and the browser
treats it as a destination — Enter follows it, middle-click opens it in a tab,
a screen reader lists it among the page's links.
A Button acts. It submits, opens a dialog,
saves, deletes. Space activates it, and it is announced as a button.
Do not swap one for the other to get a look. If a "link" saves a draft, it is a
Button with variant="ghost"; if a "button" goes to the pricing page, it is a
Button with render={<a href="/pricing" />} — or, more often, a Link.
Three more rules the reference systems agree on:
- Write labels that stand alone. "Read the full report" beats "click here" — a screen-reader user often navigates by pulling up a list of every link on the page, with no surrounding sentence to lend meaning.
- Keep icons off inline links. Inside a sentence they break the line rhythm and there is nothing for them to add.
- Do not open a new tab casually. It takes control away from the reader. If
you must, say so —
externaldoes that for you (see below).
Examples
Default
An inline link: brand ink, underlined at rest.
Variants
inline for links inside a sentence, standalone for links outside one, and
quiet when the link should carry the surrounding ink.
Underline
Each variant derives an underline, and you can pin it explicitly.
In prose
The fine-print block and the editorial paragraph — the two places inline
earns its always-on underline.
Sizes
Five sizes from xs to xl. xs is the legal and fine-print rung.
Intents
intent colours a link by meaning.
Tones
tone picks which ink the link spends. on-color reads the plate's own ink
and changes the focus ring to match — put it inside a
Surface.
External links
external opens a new tab, adds the rel guard, shows an icon, and — the
part you cannot see — appends a hidden "(opens in a new tab)" to the accessible
name.
Icons
Both slots are decorative. Use them on standalone links, not inside prose.
Standalone CTA
The "See all" / "Learn more" idiom — a section header, an empty state, an auth screen's secondary action.
Navigation lists
A footer column, where the grouping itself says "these are links" and the underline can come off.
Action links
"Change" beside a summary row. If it goes to another step it is a Link; if it edits in place it is a Button.
Visited
Opt in where a reader is choosing between many destinations.
Disabled
A disabled link leaves the tab order, loses its href, and announces itself.
Routing
render swaps the anchor for a router link and keeps everything else.
Loading
LinkSkeleton holds the link's own line box, so nothing shifts on load.
Variants
| Variant | Derived underline | Touch target | When to use |
|---|---|---|---|
inline | always | none — exempt inside text | Anchors inside a sentence or paragraph — the default |
standalone | hover | 44px minimum on phones | Navigation links and CTAs sitting outside prose |
quiet | hover | 44px minimum on phones | Links that should carry the surrounding ink |
variant sets a default; underline overrides it at any time.
Why inline is always underlined, and why it takes no touch floor. This
palette is monochrome, so a link's ink sits close to the body ink and colour
alone cannot mark it — WCAG 1.4.1. The underline is the affordance. And WCAG
2.5.5 / 2.5.8 both exempt a target inside a block of text, which is fortunate:
growing an inline link would stretch the line box of the paragraph around it.
Sizes
| Size | Text | Icon | Use case |
|---|---|---|---|
xs | text-xs | 14px | Legal and fine print |
sm | text-sm | 16px | Compact layouts, table rows |
md | text-base | 16px | Default — body copy |
lg | text-lg | 20px | Large CTAs, settings |
xl | text-xl | 24px | Hero links, marketing |
Intents
intent overrides tone for the default and muted tones. On an
on-color plate the intent hue is re-lit against the plate's own ink, so it
stays legible on any background.
| Intent | Ink | Typical use |
|---|---|---|
none | primary (primary-tinted-foreground in dark) | Default link |
error | error / error-tinted-foreground in dark | Destructive context |
warning | warning-tinted-foreground | Caution |
success | success-hover | Confirmation |
info | info | Informational |
Two of these are not the token you would guess, and both are contrast
decisions: raw warning measures 2.05:1 on white as text and raw success
3.14:1, so each ladder starts a rung further along. Every resting ink in
this table is hard-gated at WCAG AA in both themes by pnpm audit:contrast.
Tones
| Tone | Resting ink | Focus ring | Use case |
|---|---|---|---|
default | primary | ring-ring + 2px offset | Standard page surfaces |
muted | muted-foreground | ring-ring + 2px offset | De-emphasised links — footers, meta |
on-color | currentColor (the plate's ink) | ring-current, no offset | Coloured or inverted plates |
on-color changes the focus ring for a measured reason: the themed ring is
brand blue, which came out around 1.1:1 against a vivid plate — well under
the 3:1 a focus indicator has to clear — and its 2px offset gap paints the page
backdrop, banding whatever is actually underneath.
External links
external does four things:
- sets
target="_blank"(unless you pass an explicittarget) - sets
rel="noopener noreferrer"(unless you pass an explicitrel) - appends the external icon
- appends a visually-hidden "(opens in a new tab)" to the accessible name
That last one is not optional decoration. The icon renders aria-hidden — it
is a cue for sighted users only — so without the hidden text a screen-reader
user is moved to a new tab with no warning at all (WCAG 3.2.5, technique G201).
showExternalIcon={false} hides the icon and keeps the announcement.
The rel guard and the announcement both follow where the link actually opens,
not the prop, so a hand-written target="_blank" gets them too.
<Link external href="https://tessinaui.com">Tessina UI</Link>
// No icon — still announced
<Link external showExternalIcon={false} href="https://tessinaui.com">
Tessina UI
</Link>
// Your copy already says it: don't announce it twice
<Link external hideNewTabHint href="https://tessinaui.com">
Annual report (opens in a new tab)
</Link>Visited
visited paints a dimmer rung of the link ink for destinations already
visited. It is opt-in because it only helps where a reader is picking between
many destinations — a reference list, search results, a bibliography. On a nav
or a CTA that everyone clicks, it is noise.
<Link visited href="https://www.w3.org/TR/WCAG22/">WCAG 2.2</Link>Two limits worth knowing:
- It has no effect when
intentis set ortoneis notdefault. An error destination stays an error destination, and an on-color plate has no second ink to spend. - Browsers only allow colour properties on
:visited, for privacy. The state cannot also change the underline or the weight — which is fine, since the underline is already carrying "this is a link".
Disabled
<Link disabled href="/billing">Billing portal</Link>disabled mutes the link, sets aria-disabled="true", sets tabIndex={-1} so
it leaves the tab order, strips the href, and turns off pointer events — on
the plain anchor and through render. All of it, because any one alone is a
hole: with only aria-disabled, Enter still navigates.
Prefer not needing it. HTML has no disabled anchor, and several design systems deliberately ship none. If a destination is unavailable, saying why in text beats leaving a dead link on the page.
Routing
render swaps the anchor for your router's link and keeps Tessera's styling
and behaviour — the underline derivation, the focus ring, disabled, and the
external handling.
import NextLink from "next/link";
<Link render={<NextLink href="/dashboard" />}>Dashboard</Link>import { NavLink } from "react-router-dom";
<Link render={<NavLink to="/dashboard" />}>Dashboard</Link>- The host keeps its own destination. Put the
href/toon the host and Link will not stamp over it; put it on Link and it is forwarded to the host. Pick one. - The host's
classNamewins a conflict with Link's, so you can override a colour on the element you passed in. - The host's children are replaced by Link's content (label plus icons).
RTL
inline-flex with logical spacing, so dir="rtl" flips icon order and gaps.
<Link dir="rtl" variant="standalone" leadingIcon={<ArrowLeft />} href="#">
عودة إلى المشاريع
</Link>Accessibility
- Renders a native
<a>, so the role, keyboard behaviour and browser affordances are the real ones. - New tabs are announced. Any link resolving to
target="_blank"carries a visually-hidden "(opens in a new tab)" in its accessible name. rel="noopener noreferrer"is applied to every new-tab link, closing the reverse-tabnabbing hole — including throughrender.- Non-colour affordance in every variant.
inlineunderlines at rest;standaloneandquietunderline on hover and on keyboard focus. - Touch targets:
standaloneandquietare at least 44px tall on phones, as real layout rather than an invisible overflowing pad — a pad on a stacked link list overlaps its neighbours and the wrong link wins the tap.inlineis exempt per WCAG 2.5.5 / 2.5.8. - Focus ring is visible in both themes, and switches to the plate's own ink
on
tone="on-color", where the themed ring measured about 1.1:1. - Contrast: every resting ink is hard-gated at WCAG AA in light and dark by
pnpm audit:contrast. - Disabled links leave the tab order and lose their
hrefon both render paths. - Every part carries a
data-slot:link,link-leading-icon,link-trailing-icon,link-new-tab-hint,link-skeleton,link-skeleton-leading-icon,link-skeleton-text,link-skeleton-trailing-icon.
Screen readers. VoiceOver verified in both Safari and Chrome (macOS, 2026-09-09) by capturing what it actually spoke while walking the links:
| Case | VoiceOver said |
|---|---|
| External link, icon shown | "link tessinaui.com (opens in a new tab)" |
External link, icon hidden (showExternalIcon={false}) | "link No icon — still announced (opens in a new tab)" |
External link with hideNewTabHint, label says it | "link Annual report (opens in a new tab)" — announced once, not twice |
The second row is the one that matters: the icon is aria-hidden, so with it
switched off the warning survives only because it is in the accessible name.
Every link announced its role, its name and the warning; inline links read in
sentence order with the surrounding prose.
NVDA and JAWS are untested — no Windows host.
API Reference
Link
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "inline" | "standalone" | "quiet" | "inline" | Style personality. Drives the derived underline and the touch floor. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Text scale, with matching icon optics. |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Colour by meaning. Overrides tone for default/muted. |
tone | "default" | "muted" | "on-color" | "default" | Which ink to spend. on-color also changes the focus ring. |
weight | "normal" | "medium" | "semibold" | "normal" | Font weight. |
underline | "always" | "hover" | "none" | derived | inline → always, otherwise hover. Overrides the derivation. |
external | boolean | false | New tab + rel guard + icon + hidden new-tab hint. |
showExternalIcon | boolean | true | Hide the auto external icon. The hint stays. |
hideNewTabHint | boolean | false | Suppress the hidden "(opens in a new tab)" — only when your label already says it. |
visited | boolean | false | Paint visited destinations a dimmer rung. No effect with intent or tone="on-color". |
leadingIcon | ReactNode | — | Decorative icon before the label (aria-hidden). |
trailingIcon | ReactNode | — | Decorative icon after the label. Overrides the auto external icon. |
disabled | boolean | false | Mute, aria-disabled, drop href, leave the tab order. |
render | ReactElement | — | Render through another element (<NextLink>, <NavLink>). |
href | string | — | Destination. Forwarded to a render host when given. |
target | string | — | Native target. Falls back to _blank when external. |
rel | string | — | Native rel. Falls back to noopener noreferrer for any _blank link. |
dir | "ltr" | "rtl" | — | Text direction. |
className | string | — | Extra classes on the root. |
Extends all standard <a> attributes.
LinkSkeleton
Inline-level placeholder that occupies the real link's line box at the same
size, so swapping it for the link does not reflow the line.
It is phrasing content, so it goes wherever a link goes — mid-sentence
inside a <p>, a <label>, a <figcaption>. Every element it renders is a
<span>.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Match the link it stands in for. |
chars | number | 12 | Placeholder width in characters. |
showLeadingIcon | boolean | false | Mirror a leadingIcon. |
showTrailingIcon | boolean | false | Mirror a trailingIcon or the external icon. |
className | string | — | Extra classes on the root. |
Renders role="status" with aria-busy, and a visually-hidden "Loading".
Extends all standard <span> attributes.