---
title: "Link"
description: "The control that takes the reader somewhere else, and the one place the system decides how a navigation link looks, so a banner's action and a card's match."
url: "https://opsinjs.pensievelabs.org/components/link"
source: "https://opsinjs.pensievelabs.org/components/link.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["anchor", "hyperlink"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="link"
  status="shipped"
  questions="[
  &#x22;The render slot is untested against Next or React Router, so a router element that draws its own focus ring, or strips className, could leave two rings or none.&#x22;,
  &#x22;No contrast pair for the three emphases has been measured, and whether the ladder holds in greyscale without hue is argued, not tested.&#x22;,
  &#x22;On a status tint an action link takes a per-level boundary colour the caller supplies, and that colour has not been measured against the four tints in either theme.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="link" />

## Installation [#installation]

<ComponentInstall name="link" unbuilt="false" importPath="@/components/ui/link" registryDependencies="[&#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { Link } from "@/components/ui/link"
```

```tsx
<Link href="/results" emphasis="action">
  See your results
</Link>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Navigation to another page or view, written in the reader's own words for the destination.&#x22;,
  &#x22;A card's or a banner's primary action when it produces a new URL, as the action emphasis.&#x22;,
  &#x22;A quieter secondary action beside that primary one, as the secondary emphasis, which differs by a smaller box and no boundary.&#x22;,
  &#x22;A reference inside a sentence that opens a source or a related page, as the inline emphasis, underlined and reflowing with the paragraph.&#x22;,
]"
  avoid="[
  { case: &#x22;The control does something rather than going somewhere: saving a reading, submitting a form, confirming a decision. Button refuses href.&#x22;, instead: &#x22;button&#x22; },
  { case: &#x22;The whole card is the destination and the entire surface should be one target. Card carries its own wrapper anchor.&#x22;, instead: &#x22;card&#x22; },
]"
/>

## Examples [#examples]

### Each emphasis [#each-emphasis]

`emphasis` is required and has no default. `inline` sits inside a sentence,
`action` is a card's primary action and `secondary` is the quiet one beside it.

```tsx
<p>
  Your readings are yours to keep, and you can{" "}
  <Link href="/how-it-works" emphasis="inline">
    read how this measurement is worked out
  </Link>{" "}
  before you share them.
</p>

<Link href="/appointments/new" emphasis="action">
  Book a repeat appointment
</Link>

<Link href="/about/this-measurement" emphasis="secondary">
  Read about this measurement
</Link>
```

### Routing through the product's own link [#routing-through-the-products-own-link]

Pass the router link as `render` so navigation stays client-side. Link merges
its classes, its `data-slot` and its target floor onto that element and replaces
its children with the label.

```tsx
import NextLink from "next/link"
import { Link } from "@/components/ui/link"

<Link href="/results" emphasis="action" render={<NextLink href="/results" />}>
  See your results
</Link>
```

## Content guidelines [#content-guidelines]

Link text names the destination, in sentence case, without terminal punctuation.
Write it to read alone, out of a screen reader's list of links. Never *click
here*, *read more*, *learn more* or a bare *here*: each sends the reader back up
the page to reconstruct what the link is about. A link that opens in a new tab
says so in its own text; opsinjs does not add that word.

<DoDont>
  <DoDont.Do>
    **"See your results"** names where it goes and survives being read out of context.
  </DoDont.Do>

  <DoDont.Dont>
    **"Click here"** heard three times in a link list tells the reader nothing about any of them.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA** in a source pass and a rendered pass. The audit
is author-run, so it is not an independent review, and clinical review is still
pending. `pnpm run check:a11y` also runs on every commit: every colour is a role
token, no type size is in `px`, and no banned word appears anywhere.

Two moderate gaps were fixed in this pass:

* A `target="_blank"` link now defaults `rel` to `noopener noreferrer`, keeping
  any `rel` the caller supplied, and appends an `sr-only` "(opens in a new tab)"
  tail so the change of destination is spoken (2.4.4).
* The `inline` focus ring now reads `--opsin-border-focus`, so it grows when a
  low-vision consumer raises that token instead of staying flat at 2px. It still
  defaults to 2px, so nothing changes visually (2.4.7, 1.4.11).

Two items a reader should know are honestly left as they are:

* The new-tab cue is spoken only through `sr-only` text, not a visible marker,
  to preserve the visual design. A caller who wants a visible external cue adds
  their own.
* `inline` sits below the 24px target floor by design, because SC 2.5.8 exempts
  an inline target inside a sentence and a full floor would inflate the line
  box. `action` and `secondary` keep the `--opsin-target-minimum` floor in both
  axes.

Contrast for the three emphases and for the per-level boundary on a status tint
is not yet measured, and whether the ladder holds in greyscale is argued rather
than tested.

<ContrastReport component="link" />

<ContrastReport component="link" />

## API reference [#api-reference]

<PropsTable name="LinkProps" />

`href` and `emphasis` are both required, with no default for either. `ground`
defaults to `neutral` and is inert for `inline`. When `render` is passed, Link's
own `href`, `data-slot` and attributes win over the router element's, and the
two class lists are joined.

## Related [#related]

* [Button](./button.mdx) does something rather than going somewhere. It refuses `href`, so an action with no new URL stays a button.
* [Card](./card.mdx) carries its own wrapper anchor when the whole surface is the destination, rather than a Link inside it.
