opsinjs
ComponentsActions and forms

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

Theme
Density
Text
Status
link · base base · style base-lyraOpen under the product theme

Installation

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

  • The control does something rather than going somewhere: saving a reading, submitting a form, confirming a decision. Button refuses href.Use button instead.
  • The whole card is the destination and the entire surface should be one target. Card carries its own wrapper anchor.Use card instead.

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>

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

PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • Button does something rather than going somewhere. It refuses href, so an action with no new URL stays a button.
  • Card carries its own wrapper anchor when the whole surface is the destination, rather than a Link inside it.

On this page