Components
link
A native <a href> with shadcn styling. Underlined, muted, and hover-underline variants, plus an external treatment that opens a new tab and says so. The WAI-ARIA APG Link pattern's keyboard contract — Enter activates — comes from the platform, not JavaScript.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/link.json2. Use it
import { Link } from "@/components/ui/link"
<Link href="/docs">Documentation</Link>
<Link href="https://htmx.org" external>htmx.org</Link>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Link — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui has no standalone "link" primitive — it styles links through the
// Button `link` variant and the `typography` docs. We ship a dedicated,
// text-first anchor instead. Anatomy/intent cross-checked against the Button
// `link` variant: repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/button.tsx.
//
// Accessibility contract — WAI-ARIA APG Link pattern:
// repos/aria-practices/content/patterns/link/link-pattern.html
// The APG itself says: "Authors are strongly encouraged to use a native host
// language link element, such as an HTML <a> element with an href attribute."
// So we render a real <a href>. That gives us, for free and without any JS:
// - the implicit `link` role (MDN: <a> has role=link when href is present —
// repos/mdn/files/en-us/web/html/reference/elements/a/index.md "Implicit
// ARIA role"),
// - Enter activates + moves focus to the target (the APG keyboard contract —
// link-pattern.html "Keyboard Interaction": Enter executes the link),
// - browser affordances the APG example flags as lost when you fake a link
// with role=link on a <span>: open-in-new-tab, copy-link, drag, Shift+F10
// context menu.
// The APG link *examples* (link/examples/link.html) only reach for
// role=link + tabindex=0 + onkeydown when the markup genuinely cannot be an
// <a> (a <span> or <img>). We expose that fallback via `as` + `role="link"`,
// but the default — and the path we document — is the native element.
//
// `external` sets target/rel and renders a visible "opens in new tab" icon +
// visually-hidden text, per MDN's "External links" guidance
// (a/index.md "External links and linking to non-HTML resources"). Modern
// browsers treat target="_blank" as rel="noopener" implicitly; we still emit
// rel="noopener noreferrer" so the protection is explicit and back-compatible.
export type LinkVariant =
| "default" // underlined, primary colour — reads as a link in prose
| "muted" // muted-foreground, underlined — low-emphasis inline link
| "hover" // no underline at rest, underline on hover/focus — nav/menu link
const base =
"inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none " +
// Native <a> is keyboard-focusable; render the same focus ring as the rest
// of the library so the focus state is obvious. ring-ring/50 + a 2px ring.
"focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
// role=link fallback (non-anchor) must look identical and not show a text
// cursor; the platform won't give it pointer affordance the way <a> does.
"[&[role=link]]:cursor-pointer " +
// Decorative SVGs inside the link (the external-link glyph) get sized here
// so callers don't have to.
"[&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0"
const variants: Record<LinkVariant, string> = {
default: "underline decoration-primary/40 hover:decoration-primary",
muted:
"text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground",
hover: "no-underline hover:underline",
}
export function linkClasses(opts?: {
variant?: LinkVariant
class?: ClassValue
}): string {
const variant = opts?.variant ?? "default"
return cn(base, variants[variant], opts?.class)
}
type LinkProps = PropsWithChildren<{
variant?: LinkVariant
class?: ClassValue
// The destination. Native <a href>. Omitting href yields a non-link
// <a> (generic role) — usually you want href.
href?: string
// Treat the link as external: opens in a new browsing context and appends
// the "opens in new tab" affordance (icon + SR-only text). See MDN
// "External links" guidance. Sets target="_blank" rel="noopener noreferrer".
external?: boolean
// Standard <a> attributes (MDN). target/rel are managed by `external` but
// can be set explicitly too.
target?: "_self" | "_blank" | "_parent" | "_top" | (string & {})
rel?: string
download?: boolean | string
hreflang?: string
referrerpolicy?: string
ping?: string
type?: string
id?: string
ariaLabel?: string
ariaLabelledby?: string
// aria-describedby is a global ARIA attribute valid on the implicit `link`
// role (MDN: <a href> exposes role=link). Reference a description distinct
// from the link text — e.g. "PDF, 2MB" / "opens in new tab".
ariaDescribedby?: string
ariaCurrent?: "page" | "step" | "location" | "date" | "time" | "true" | "false"
// APG fallback only: render a non-anchor element with role="link". The
// platform will NOT navigate for you — wire navigation yourself (see docs).
// We still add tabindex=0 + role=link so it's reachable and announced.
as?: "a" | "span" | "button"
role?: "link"
// htmx v4 — e.g. boost a same-origin link into a fetch+swap. See
// repos/htmx/www/src/content/reference/.
"hx-get"?: string
"hx-target"?: string
"hx-swap"?: string
"hx-boost"?: string
"hx-push-url"?: string
}>
export function Link(props: LinkProps) {
const {
children,
variant,
class: className,
href,
external,
target,
rel,
as,
role,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaCurrent,
...rest
} = props
const Tag: any = as ?? "a"
const isAnchor = Tag === "a"
// External: open in a new tab and make that explicit. rel="noopener
// noreferrer" drops window.opener + the Referer header.
const resolvedTarget = external ? (target ?? "_blank") : target
const resolvedRel = external ? (rel ?? "noopener noreferrer") : rel
// APG fallback: a non-anchor element must be told it's a link (role=link)
// and put in the tab order (tabindex=0). A native <a> already has both for
// free — never override them. role="link" passed explicitly on an <a> is
// ignored (it's already the implicit role).
const resolvedRole = isAnchor ? undefined : "link"
const tabindex = isAnchor ? undefined : 0
const classes = linkClasses({ variant, class: className })
return (
<Tag
id={props.id}
href={isAnchor ? href : undefined}
target={isAnchor ? resolvedTarget : undefined}
rel={isAnchor ? resolvedRel : undefined}
role={resolvedRole}
tabindex={tabindex}
// APG fallback: href is invalid on a non-anchor, so the browser won't
// navigate (APG link/examples/link.html). Pass the destination through as
// data-href so site.js can wire Enter/click on [role=link][data-href].
data-href={!isAnchor ? href : undefined}
data-slot="link"
data-variant={variant ?? "default"}
data-external={external ? "true" : undefined}
class={classes}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-current={ariaCurrent}
{...rest}
>
{children}
{/* External-link glyph. aria-hidden — the SR-only text carries the
meaning for assistive tech (MDN "External links" guidance). */}
{external && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
)}
{external && <span class="sr-only"> (opens in new tab)</span>}
</Tag>
)
}
1. Save the file
Copy link.html into templates/components/.
2. Use it
{% from "components/link.html" import link %}
{{ link("Documentation", href="/docs") }}
{{ link("htmx.org", href="https://htmx.org", external=true) }}View source
{# Link macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/link.tsx. Renders a native <a href> — the WAI-ARIA APG
Link pattern (repos/aria-practices/content/patterns/link/link-pattern.html)
"strongly encourages" a real <a>, so role=link and Enter activation come
from the platform.
Usage:
{% from "components/link.html" import link %}
{{ link("Documentation", href="/docs") }}
{{ link("Settings", href="/settings", variant="hover") }}
{{ link("htmx.org", href="https://htmx.org", external=true) }}
external=true sets target="_blank" rel="noopener noreferrer" and appends the
"opens in new tab" icon + visually-hidden text (MDN: External links).
as="span"/"button" + role="link" is the APG fallback for markup that cannot
be an <a>; you must wire navigation yourself. Prefer the native <a>. #}
{% macro link(
text,
href=none,
variant="default",
external=false,
target=none,
rel=none,
as="a",
id=none,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
aria_current=none,
extra_class="",
**attrs
) %}
{%- set base -%}
inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0
{%- endset -%}
{%- set variants = {
"default": "underline decoration-primary/40 hover:decoration-primary",
"muted": "text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground",
"hover": "no-underline hover:underline"
} -%}
{%- set is_anchor = (as == "a") -%}
{%- set resolved_target = target if target is not none else ("_blank" if external else none) -%}
{%- set resolved_rel = rel if rel is not none else ("noopener noreferrer" if external else none) -%}
<{{ as }}
{%- if id %} id="{{ id }}"{% endif %}
{%- if is_anchor and href %} href="{{ href }}"{% endif %}
{%- if is_anchor and resolved_target %} target="{{ resolved_target }}"{% endif %}
{%- if is_anchor and resolved_rel %} rel="{{ resolved_rel }}"{% endif %}
{#- APG fallback: href is invalid on a non-anchor; expose it as data-href so
site.js can navigate [role=link][data-href] (link/examples/link.html). #}
{%- if not is_anchor %} role="link" tabindex="0"{% if href %} data-href="{{ href }}"{% endif %}{% endif %}
data-slot="link"
data-variant="{{ variant }}"
{%- if external %} data-external="true"{% endif %}
class="{{ base }} {{ variants[variant] }} {{ extra_class }}"
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if aria_current %} aria-current="{{ aria_current }}"{% endif %}
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ text }}{% if external %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg><span class="sr-only"> (opens in new tab)</span>{% endif %}</{{ as }}>
{% endmacro %}
1. Save the file
Add link.tmpl alongside your other templates. The template uses sprig's dict + a htmlSafe helper for the optional rich body.
2. Use it
{{template "link" (dict "Text" "Documentation" "Href" "/docs")}}
{{template "link" (dict "Text" "htmx.org" "Href" "https://htmx.org" "External" true)}}View source
{{/*
Link template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/link.tsx. Emits a native <a href>, so the WAI-ARIA APG
Link pattern's keyboard contract (Enter activates) and the implicit link
role come from the platform. See
repos/aria-practices/content/patterns/link/link-pattern.html.
Usage:
type LinkArgs struct {
Text string
Href string
Variant string // default | muted | hover
External bool // target=_blank rel="noopener noreferrer" + icon
Target string
Rel string
As string // "a" (default) | "span" | "button" (role=link fallback)
ID string
AriaLabel string
AriaLabelledby string
AriaDescribedby string // id of an element describing the link
AriaCurrent string // page | step | location | date | time | true | false
Body template.HTML // optional rich body; falls back to .Text
Attrs map[string]string // hx-*, download, etc.
}
tpl.ExecuteTemplate(w, "link", LinkArgs{
Text: "Documentation", Href: "/docs",
})
external=true follows MDN "External links" guidance: visible icon + SR-only
"(opens in new tab)" text.
*/}}
{{define "link"}}
{{- $variant := or .Variant "default" -}}
{{- $as := or .As "a" -}}
{{- $isAnchor := eq $as "a" -}}
{{- $base := "inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0" -}}
{{- $variants := dict
"default" "underline decoration-primary/40 hover:decoration-primary"
"muted" "text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground"
"hover" "no-underline hover:underline" -}}
{{- $target := .Target -}}
{{- if and .External (eq $target "")}}{{$target = "_blank"}}{{end -}}
{{- $rel := .Rel -}}
{{- if and .External (eq $rel "")}}{{$rel = "noopener noreferrer"}}{{end -}}
<{{$as}}
{{- if .ID}} id="{{.ID}}"{{end}}
{{- if and $isAnchor .Href}} href="{{.Href}}"{{end}}
{{- if and $isAnchor $target}} target="{{$target}}"{{end}}
{{- if and $isAnchor $rel}} rel="{{$rel}}"{{end}}
{{- /* APG fallback: href is invalid on a non-anchor; expose it as data-href so
site.js can navigate [role=link][data-href] (link/examples/link.html). */ -}}
{{- if not $isAnchor}} role="link" tabindex="0"{{if .Href}} data-href="{{.Href}}"{{end}}{{end}}
data-slot="link" data-variant="{{$variant}}"
{{- if .External}} data-external="true"{{end}}
class="{{$base}} {{index $variants $variant}}"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
{{- if .AriaCurrent}} aria-current="{{.AriaCurrent}}"{{end}}
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{if .Body}}{{htmlSafe .Body}}{{else}}{{.Text}}{{end}}{{if .External}}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg><span class="sr-only"> (opens in new tab)</span>{{end}}</{{$as}}>
{{end}}
1. Save the file
Drop link.ex into lib/my_app_web/components/. The function is link_/1 to avoid clashing with Phoenix's built-in link/1.
2. Use it
alias ShadcnHtmx.Components.Link
<Link.link_ href="/docs">Documentation</Link.link_>
<Link.link_ href="https://htmx.org" external>htmx.org</Link.link_>View source
defmodule ShadcnHtmx.Components.Link do
@moduledoc """
Link — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/link.tsx. Renders a native `<a href>`, which the
WAI-ARIA APG Link pattern "strongly encourages" over a faked role=link
element — see
repos/aria-practices/content/patterns/link/link-pattern.html. The native
anchor gives us the implicit `link` role and Enter-activates-the-link
keyboard behaviour with no JavaScript.
## Examples
<.link_ href="/docs">Documentation</.link_>
<.link_ href="/settings" variant="hover">Settings</.link_>
<.link_ href="https://htmx.org" external>htmx.org</.link_>
`external` sets `target="_blank" rel="noopener noreferrer"` and appends the
"opens in new tab" icon + visually-hidden text (MDN: External links).
`as="span"`/`"button"` renders the APG role=link fallback for markup that
cannot be an `<a>`; you must wire navigation yourself. Prefer the native
`<a>`.
The function is named `link_` to avoid clashing with Phoenix.Component's
built-in `link/1`.
"""
use Phoenix.Component
@base "inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none " <>
"focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"[&[role=link]]:cursor-pointer " <>
"[&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0"
@variants %{
"default" => "underline decoration-primary/40 hover:decoration-primary",
"muted" =>
"text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground",
"hover" => "no-underline hover:underline"
}
attr :variant, :string, default: "default", values: ~w(default muted hover)
attr :href, :string, default: nil
attr :external, :boolean, default: false
attr :target, :string, default: nil
attr :rel, :string, default: nil
attr :as, :string, default: "a", values: ~w(a span button)
attr :class, :string, default: nil
attr :rest, :global,
include:
~w(hx-get hx-target hx-swap hx-boost hx-push-url
download hreflang referrerpolicy ping type
id aria-label aria-labelledby aria-describedby aria-current)
slot :inner_block, required: true
# APG fallback: href is invalid on a non-anchor element, so the browser will
# not navigate (link/examples/link.html). We expose the destination as
# data-href below so site.js can wire Enter/click on [role=link][data-href].
def link_(assigns) do
is_anchor = assigns.as == "a"
target = assigns.target || if(assigns.external, do: "_blank")
rel = assigns.rel || if(assigns.external, do: "noopener noreferrer")
assigns =
assigns
|> assign(:variant_class, Map.fetch!(@variants, assigns.variant))
|> assign(:base_class, @base)
|> assign(:is_anchor, is_anchor)
|> assign(:resolved_target, if(is_anchor, do: target))
|> assign(:resolved_rel, if(is_anchor, do: rel))
~H"""
<.dynamic_tag
tag_name={@as}
href={if @is_anchor, do: @href}
target={@resolved_target}
rel={@resolved_rel}
role={if !@is_anchor, do: "link"}
tabindex={if !@is_anchor, do: "0"}
data-href={if !@is_anchor, do: @href}
data-slot="link"
data-variant={@variant}
data-external={if @external, do: "true"}
class={[@base_class, @variant_class, @class]}
{@rest}
>
{render_slot(@inner_block)}<svg
:if={@external}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><path d="M7 17 17 7" /><path d="M7 7h10v10" /></svg><span :if={@external} class="sr-only"> (opens in new tab)</span>
</.dynamic_tag>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css. The trailing <script> only boots the role=link fallback — native <a> needs none of it.
2. Use it
<a href="/docs" data-slot="link" data-variant="default"
class="inline-flex items-center gap-1 rounded-sm font-medium
text-primary underline-offset-4 underline decoration-primary/40 …">
Documentation
</a>View source
<!--
shadcn-htmx — raw HTML link snippets.
No template engine, no JavaScript framework. Just a native <a href> with the
class strings you need, ready to drop into any HTML file that loads
Tailwind CSS v4.
Why a real <a>? The WAI-ARIA APG Link pattern strongly encourages the native
element: role=link and Enter-to-activate come from the browser, and you keep
open-in-new-tab, copy-link, and the context menu — all of which break when
you fake a link with role=link on a <span>. See
repos/aria-practices/content/patterns/link/link-pattern.html.
Requirements:
1. Tailwind CSS v4 (or the Play CDN for quick experiments).
2. The shadcn CSS variables (--primary, --muted-foreground, --ring, …).
Copy the :root / .dark blocks from app/styles/input.css.
BASE (shared by every variant):
inline-flex items-center gap-1 rounded-sm font-medium text-primary
underline-offset-4 transition-colors outline-none
focus-visible:ring-[3px] focus-visible:ring-ring/50
[&[role=link]]:cursor-pointer
[&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0
-->
<!-- ─── Variants ────────────────────────────────────────────────────── -->
<!-- default — underlined, primary colour; reads as a link in prose -->
<a href="/docs" data-slot="link" data-variant="default"
class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">
Documentation
</a>
<!-- muted — low-emphasis inline link -->
<a href="/changelog" data-slot="link" data-variant="muted"
class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground">
Changelog
</a>
<!-- hover — no underline at rest, underline on hover/focus; nav / menu link -->
<a href="/settings" data-slot="link" data-variant="hover"
class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 no-underline hover:underline">
Settings
</a>
<!-- ─── External (opens in new tab) ─────────────────────────────────── -->
<!--
target="_blank" implicitly behaves like rel="noopener" in modern browsers;
we still write rel="noopener noreferrer" so it's explicit. The icon is
aria-hidden; the SR-only span tells screen-reader users what will happen.
-->
<a href="https://htmx.org" target="_blank" rel="noopener noreferrer"
data-slot="link" data-variant="default" data-external="true"
class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">
htmx.org
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 17 17 7" /><path d="M7 7h10v10" />
</svg>
<span class="sr-only"> (opens in new tab)</span>
</a>
<!-- ─── In prose ────────────────────────────────────────────────────── -->
<p class="text-sm text-muted-foreground">
Learn more
<a href="/docs/link" data-slot="link" data-variant="default"
class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">about links</a>.
</p>
<!-- ─── APG fallback (only when an <a> is impossible) ───────────────── -->
<!--
The APG link examples (link/examples/link.html) use a <span> with
role="link" + tabindex="0" + a keydown handler ONLY when the element cannot
be an anchor. The platform does NOT navigate for you here — wire it up with
JS. This is the exception, not the rule. Prefer the native <a> above.
-->
<span role="link" tabindex="0" data-slot="link" data-variant="default"
data-href="/docs"
class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer underline decoration-primary/40 hover:decoration-primary">
Span pretending to be a link
</span>
<script>
// Minimal boot for the role=link fallback only. Native <a href> needs none
// of this. Mirrors the APG example contract: Enter activates the link.
(function () {
function go(el) {
var href = el.getAttribute("data-href")
if (href) window.location.href = href
}
document.querySelectorAll('[data-slot="link"][role="link"]').forEach(function (el) {
el.addEventListener("click", function () { go(el) })
el.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); go(el) }
})
})
})()
</script>
Examples
Variants
Three emphasis levels — all the same native <a href>, only the underline / colour treatment changes.
default is the prose link: primary colour, always underlined so it's distinguishable without relying on colour alone (WCAG 1.4.1). muted steps back for footers and metadata. hover drops the underline until hover/focus — reserve it for navigation and menus where the surrounding context already signals "this is a link", never for links buried in body text.
<Link href="/docs/link">Default link</Link>
<Link href="/docs/link" variant="muted">Muted link</Link>
<Link href="/docs/link" variant="hover">Hover-underline link</Link>{{ link("Default link", href="/docs/link") }}
{{ link("Muted link", href="/docs/link", variant="muted") }}
{{ link("Hover-underline link", href="/docs/link", variant="hover") }}{{template "link" (dict "Text" "Default link" "Href" "/docs/link")}}
{{template "link" (dict "Text" "Muted link" "Href" "/docs/link" "Variant" "muted")}}
{{template "link" (dict "Text" "Hover-underline link" "Href" "/docs/link" "Variant" "hover")}}<Link.link_ href="/docs/link">Default link</Link.link_>
<Link.link_ href="/docs/link" variant="muted">Muted link</Link.link_>
<Link.link_ href="/docs/link" variant="hover">Hover-underline link</Link.link_><div class="flex flex-wrap items-center justify-center gap-6">
<a href="/docs/link" data-slot="link" data-variant="default" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">Default link</a>
<a href="/docs/link" data-slot="link" data-variant="muted" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground">Muted link</a>
<a href="/docs/link" data-slot="link" data-variant="hover" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 no-underline hover:underline">Hover-underline link</a>
</div>Further reading
External — opens in a new tab, and says so
external sets target=_blank rel="noopener noreferrer" and appends an icon plus visually-hidden "(opens in new tab)" text.
MDN's guidance on external links is explicit: a link that opens a new tab "should indicate what will happen when the link is followed", because an unexpected new window confuses people with low vision, screen-reader users, and people with cognitive concerns. So external renders a visible glyph plus a sr-only span. It also writes rel="noopener noreferrer": modern browsers imply noopener for _blank, but writing it keeps the protection explicit and severs the window.opener reference and the referrer.
<Link href="https://htmx.org" external>htmx.org</Link>
<Link href="https://tailwindcss.com" external variant="muted">
Tailwind CSS
</Link>{{ link("htmx.org", href="https://htmx.org", external=true) }}
{{ link("Tailwind CSS", href="https://tailwindcss.com", external=true, variant="muted") }}{{template "link" (dict "Text" "htmx.org" "Href" "https://htmx.org" "External" true)}}
{{template "link" (dict "Text" "Tailwind CSS" "Href" "https://tailwindcss.com" "External" true "Variant" "muted")}}<Link.link_ href="https://htmx.org" external>htmx.org</Link.link_>
<Link.link_ href="https://tailwindcss.com" external variant="muted">
Tailwind CSS
</Link.link_><div class="flex flex-wrap items-center justify-center gap-6">
<a href="https://htmx.org" target="_blank" rel="noopener noreferrer" data-slot="link" data-variant="default" data-external="true" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">
htmx.org
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 17 17 7">
</path>
<path d="M7 7h10v10">
</path>
</svg>
<span class="sr-only">(opens in new tab)</span>
</a>
<a href="https://tailwindcss.com" target="_blank" rel="noopener noreferrer" data-slot="link" data-variant="muted" data-external="true" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 text-muted-foreground underline decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground">
Tailwind CSS
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 17 17 7">
</path>
<path d="M7 7h10v10">
</path>
</svg>
<span class="sr-only">(opens in new tab)</span>
</a>
</div>In prose
Links flow inline with text. The default variant keeps a visible underline so it stands out from surrounding prose.
Link text should make sense out of context — assistive tech can list every link on a page, so "click here" reads as a wall of "click here". MDN's own example fixes weak link text by moving the anchor onto the meaningful words. Write learn about our components, not learn more here.
This component renders a real anchor, so you keep every browser affordance. Read the APG Link pattern (opens in new tab) for the full contract, or jump straight to the API reference below.
<p>
Read the{" "}
<Link href="https://www.w3.org/WAI/ARIA/apg/patterns/link/" external>
APG Link pattern
</Link>{" "}
for the full contract, or jump to the{" "}
<Link href="#api">API reference</Link>.
</p><p>
Read the {{ link("APG Link pattern", href="https://www.w3.org/WAI/ARIA/apg/patterns/link/", external=true) }}
for the full contract, or jump to the {{ link("API reference", href="#api") }}.
</p><p>
Read the {{template "link" (dict "Text" "APG Link pattern" "Href" "https://www.w3.org/WAI/ARIA/apg/patterns/link/" "External" true)}}
for the full contract, or jump to the {{template "link" (dict "Text" "API reference" "Href" "#api")}}.
</p><p>
Read the
<Link.link_ href="https://www.w3.org/WAI/ARIA/apg/patterns/link/" external>APG Link pattern</Link.link_>
for the full contract, or jump to the
<Link.link_ href="#api">API reference</Link.link_>.
</p><p class="max-w-prose px-6 py-4 text-sm leading-relaxed text-foreground">
This component renders a real anchor, so you keep every browser affordance. Read the
<a href="https://www.w3.org/WAI/ARIA/apg/patterns/link/" target="_blank" rel="noopener noreferrer" data-slot="link" data-variant="default" data-external="true" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">
APG Link pattern
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 17 17 7">
</path>
<path d="M7 7h10v10">
</path>
</svg>
<span class="sr-only">(opens in new tab)</span>
</a>
for the full contract, or jump straight to the
<a href="#api" data-slot="link" data-variant="default" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">API reference</a>
below.
</p>Further reading
role=link fallback (last resort)
When the markup genuinely cannot be an <a> — e.g. a <span> inside content you don't control — render role=link + tabindex=0 and wire navigation yourself.
The APG Link examples build links out of <span> and <img> with role="link" + tabindex="0" + onkeydown, but the pattern's own note warns that applying role="link" does not give you navigation, the context menu, or copy-link — those become your job. Pass as="span" to opt in. The shared keyboard handler (Enter activates) lives in site.js, keyed on [data-slot="link"][role="link"]. This is the exception — reach for a real <a> every other time.
// Last resort — the platform won't navigate for you.
// site.js reads data-href on [data-slot="link"][role="link"].
<Link as="span" data-href="/docs/link">Span as link</Link>{{ link("Span as link", as="span", data_href="/docs/link") }}{{template "link" (dict "Text" "Span as link" "As" "span" "Attrs" (dict "data-href" "/docs/link"))}}<Link.link_ as="span" data-href="/docs/link">Span as link</Link.link_><div class="flex flex-wrap items-center justify-center gap-6">
<span role="link" tabindex="0" data-href="/docs/link" data-slot="link" data-variant="default" class="inline-flex items-center gap-1 rounded-sm font-medium text-primary underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&[role=link]]:cursor-pointer [&>svg]:pointer-events-none [&>svg]:size-3.5 [&>svg]:shrink-0 underline decoration-primary/40 hover:decoration-primary">Span as link</span>
</div>Further reading
API Reference
<Link>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaDescribedby | string | — | Id of an element describing the link (announced after its name) — e.g. a "PDF, 2MB" or "opens in new tab" note. Applied to both the native anchor and the role=link fallback.MDNaria-describedby |
href | string | — | Destination URL. Renders a native <a href> with the implicit link role and Enter-to-activate from the platform.MDN<a href> |
variant | "default"|"muted"|"hover" | "default" | Emphasis: default (underlined primary), muted (low-emphasis), hover (underline only on hover/focus — for nav, not prose). |
external | boolean | false | Open in a new tab. Sets target="_blank" rel="noopener noreferrer" and appends an icon + visually-hidden "(opens in new tab)" text.MDNExternal links |
target / rel | string | — | Standard <a> attributes. Managed by `external`, but settable explicitly.MDN<a target> |
download / hreflang / referrerpolicy / ping / type | string | — | Pass-through native <a> attributes.MDN<a> attributes |
ariaCurrent | "page"|"step"|"location"|"date"|"time"|"true"|"false" | — | Marks the link to the current resource (e.g. the active nav item).MDNaria-current |
as | "a"|"span"|"button" | "a" | APG fallback only — render a non-anchor with role="link" + tabindex=0 when an <a> is impossible. The platform will NOT navigate for you; site.js wires Enter/click on [data-slot="link"][role="link"] via a data-href hook.APGLink pattern note |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |