External Link
An anchor that decides whether an href leaves your site — SSR-stable, origin declared as a prop — then adds an icon, the noopener patch and a new-window announcement to the ones that do, and nothing at all to the ones that do not.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/external-link.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ExternalLink" component (no client
runtime, no animation library, no icon package — one hand-rolled inline SVG).
Contract
- forwardRef anchor extending React.AnchorHTMLAttributes<HTMLAnchorElement>
with href typed as REQUIRED string. A link component that compiles without a
destination is exactly how href="#" placeholders ship.
- Own props:
siteOrigin?: string your own origin, "https://example.com" or a
bare "example.com"
internalHosts?: string[] further hosts that also count as yours
includeSubdomains?: boolean default false
external?: boolean skip detection; true forces the external
treatment, false forces the untouched one
icon?: React.ReactNode default arrow; false or null removes it
noReferrer?: boolean default false
newWindowLabel?: string default "(opens in a new window)", "" opts out
- Export the classifier as a pure function too:
resolveLinkKind(href, { siteOrigin, internalHosts, includeSubdomains })
-> "internal" | "external" | "handoff"
Consumers need it to write an MDX `a` renderer that picks between this
component and their router's Link, and it is the only part worth unit testing.
- No "use client". Everything below is a pure function of props, so the
component renders on the server and ships zero JavaScript.
Behavior
- Where the origin comes from decides whether this component is usable at all.
Reading window.location.origin makes the server classify an absolute
self-link as external (there is no location on the server) and the client
classify it as internal — a hydration mismatch by construction, on exactly
the links a CMS emits. The origin is therefore a prop. When no origin is
declared, every absolute http(s) URL is external: right when internal links
are relative, and deterministic either way.
- Classification, in order:
empty / "#frag" / "?q=1" -> internal (never leaves the document)
"//host/path" -> protocol-relative; compare host only
no scheme ("/docs", "./a", "a/b")-> internal
scheme other than http(s) -> handoff (mailto:, tel:, sms:, …)
http(s) -> internal iff the host matches a declared
one, or ends with "." + one when
includeSubdomains
Compare host (hostname plus non-default port), lowercased, via the URL
parser; never string-match on the href. Ignore the http/https difference —
a scheme upgrade is not a different site. An http(s) URL that fails to parse
is external: do not claim what you cannot read.
- Whether a subdomain is "your site" is a product decision (docs.example.com is
often another app entirely), so it is a prop, not a heuristic, and it defaults
to off.
- Two independent axes, deliberately not collapsed into one flag:
the icon and the data attribute follow the DESTINATION;
the rel patch and the announcement follow the TARGET.
A consumer who puts target="_blank" on an internal link gets the patch and
the announcement but no arrow; an external link forced to target="_self" gets
the arrow but no patch. Only external links default to target="_blank";
a handoff never does, because mailto:/tel: open an app, not a window.
- rel. target="_blank" always gets "noopener" — without it the opened document
holds a live window.opener handle and can navigate this tab elsewhere.
"noreferrer" is a separate prop defaulting to false: it strips the Referer
header, so the destination can no longer see that the traffic came from you.
That breaks referral attribution on both ends and is usually the wrong trade
for partner links; turn it on for untrusted or user-submitted URLs, where not
leaking the current URL is worth more. Merge into whatever rel the consumer
already wrote, token by token, case-insensitively, without duplicating.
- Announcement, exactly once. The icon is aria-hidden and a visually hidden
sentence joins the accessible name ("MDN on the anchor element (opens in a
new window)"). Do NOT also give the icon an aria-label — that is the same
fact twice. The sentence is keyed on target="_blank", not on externality: a
mailto: link that announces a new window is lying.
- The icon must never start a line of its own. An inline icon is an atomic box
and the line breaker may break before one, so the arrow drops to the next line
whenever the last word happens to end the line. Emit U+00A0 NO-BREAK SPACE
between the children and the icon; it forbids that break and doubles as the
gap, so there is no margin to keep in sync. Two things that look like they
should work and do not: U+2060 WORD JOINER (Chromium does not apply it against
an atomic inline) and white-space: nowrap on the icon wrapper (the break the
browser takes is the one *before* the wrapper). Measured over a 110-340px
container-width sweep at two font sizes: no separator, U+2060 and the nowrap
wrapper each orphan the arrow at 2 widths per size; U+00A0 at none.
- Internal links are passed straight through: no icon, no rel, no target, no
data attribute, and no empty class attribute either (cn() returns "" for no
input — coerce it to undefined). Half the value of this component is what it
does not do; it has to be safe as the link renderer for an entire MDX
pipeline, where most links are in-app.
- Nothing animates, so there is nothing for prefers-reduced-motion to turn off.
Rendering & styling
- Semantic tokens only, and barely any of them: the component sets no color, no
underline and no focus style — the surrounding prose owns all three, and the
anchor keeps the platform focus ring. The icon is stroke="currentColor" so it
follows the text.
- The icon is sized in em (0.8em) so it tracks the type size rather than the
root font size, and sits on the baseline like a glyph. Its wrapper carries no
margin: the no-break space in front of it is the gap.
- Merge className with cn(); spread the consumer's remaining props last so
data-*, event handlers and aria-* all pass through.
- data-link-kind="external" | "handoff" is emitted for styling hooks (swap the
icon per kind in CSS); internal links carry no attribute at all.
Customization levers
- siteOrigin / internalHosts / includeSubdomains are the policy surface: one
origin for a single-domain site, several hosts for a marketing + docs + app
split, includeSubdomains when every *.brand.com is one product. Note that
includeSubdomains matches subdomains of the hosts you DECLARE, so declare the
apex — with siteOrigin "https://www.brand.com" the flag does nothing for
docs.brand.com; add "brand.com" to internalHosts and it works.
- icon takes any node — a Mail glyph for mailto:, a GitHub mark for repo links,
or false for a footer of social icons that already reads as outbound. Style
per kind with [data-link-kind="handoff"] instead of branching in JSX.
- newWindowLabel is the i18n hook; translate it, or set it to "" if your layout
already announces new windows globally.
- noReferrer per link (or wrap the component and default it to true for
user-generated content).
- target is yours: pass "_self" to keep an outbound link in the tab, or drop
the default entirely by wrapping the component and passing target={undefined}
— nothing here forces a new window.
- Wrapping. The component imposes no wrapping of its own, so external and
internal links in the same paragraph break identically. Link text that is a
raw URL needs wrap-anywhere in className — and be aware of the one case the
no-break space cannot cover: overflow-wrap: anywhere (or break-word) allows a
break between ANY two characters, including the one right before the icon
tail, so a URL-as-link-text can still strand its arrow on the last line.
Measured in Edge/Chromium over a 130-330px sweep: 9 of 101 widths, and
identically for a nowrap wrapper, an overflow-wrap: normal wrapper and an
::after glyph — it is the wrapping mode, not the technique. The fix is
editorial: give the link a human-readable label instead of a raw URL (better
for screen readers anyway), or pass icon={false} on URL-as-text links.Concepts
- Origin as a prop, not a lookup —
window.locationdoes not exist while the server renders, so a component that reads it classifies absolute self-links one way on the server and the other way in the browser. Declaring the origin turns the decision into a pure function of props, which is the only version that survives hydration and the only version that can run without"use client". - Destination axis vs. target axis — the arrow answers "does this leave the site?";
rel="noopener"and the new-window sentence answer "does this open a browsing context?". They are different questions, so an internal link withtarget="_blank"is patched and announced but not decorated, andmailto:is decorated but neither patched nor announced. - noopener is safety, noreferrer is policy — the first stops the opened document from steering this tab through
window.opener; the second also strips theRefererheader, which costs you referral attribution. One is mandatory, the other is a per-link decision. - Announce once — the icon is
aria-hiddenand the hint issr-onlytext inside the anchor, so it lands in the accessible name exactly once. Labelling the icon and keeping the hint reads the same fact twice; doing neither leaves the new window unannounced. - A no-break space is the icon glue — an inline icon is an atomic box and the line breaker is free to break before it, which is how arrows end up alone on a line under their own link.
U+00A0forbids that break and is the gap at the same time. The two obvious alternatives fail:white-space: nowrapon the wrapper does not stop the break before the wrapper, andU+2060WORD JOINER — which is supposed to forbid breaks on both sides — is not applied against an atomic inline in Chromium. The one wrapping mode no glue survives isoverflow-wrap: anywhere, which by definition allows a break between any two characters; that is a property of break-anywhere text, not of the technique. - Doing nothing is a feature — internal links come out byte-identical to what you wrote, which is what makes it safe to install as the
arenderer for a whole MDX pipeline instead of hand-tagging outbound links.
Anchor Heading
A heading that owns its anchor — a stable id de-duplicated against the live document, a permanently focusable link that fades in on hover, and one click that deep-links and copies the URL.
Kinetic Headline
A fixed sentence with one rotating slot whose width springs to each incoming word, so the rest of the line glides instead of snapping.