Link
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.
Preview
link · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/link
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Link } from "@/components/ui/link"<Link href="/results" emphasis="action">
See your results
</Link>When to use it
Use it when
- Navigation to another page or view, written in the reader's own words for the destination.
- A card's or a banner's primary action when it produces a new URL, as the action emphasis.
- A quieter secondary action beside that primary one, as the secondary emphasis, which differs by a smaller box and no boundary.
- A reference inside a sentence that opens a source or a related page, as the inline emphasis, underlined and reflowing with the paragraph.
Do not use it when
Examples
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.
<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
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.
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
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.
Do
"See your results" names where it goes and survives being read out of context.
Don’t
"Click here" heard three times in a link list tells the reader nothing about any of them.
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 defaultsreltonoopener noreferrer, keeping anyrelthe caller supplied, and appends ansr-only"(opens in a new tab)" tail so the change of destination is spoken (2.4.4). - The
inlinefocus 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-onlytext, not a visible marker, to preserve the visual design. A caller who wants a visible external cue adds their own. inlinesits 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.actionandsecondarykeep the--opsin-target-minimumfloor 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.
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from LinkProps in registry/bases/base/link.tsx.
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
Button
A control you press to make something happen. It is the least clinical component here, and its page sets the pattern every other component page follows.
Field
The wrapper that gives every input a visible label, a hint, an error message and the wiring between them. It is why no control here can ship without a name.