Text

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.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * External Link — works out whether an href leaves your site, and adds exactly
 * three things to the ones that do: an icon, the `rel` patch that makes
 * `target="_blank"` safe, and a screen-reader hint that a new window is coming.
 *
 * Two decisions shape the whole implementation:
 *
 * 1. **The origin is a prop, not a runtime lookup.** Reading
 *    `window.location.origin` would make the server render an absolute
 *    self-link as "external" (no location on the server) and the client render
 *    it as "internal" — a hydration mismatch by construction, on the exact

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/external-link.json

Prompt

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 lookupwindow.location does 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 with target="_blank" is patched and announced but not decorated, and mailto: 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 the Referer header, which costs you referral attribution. One is mandatory, the other is a per-link decision.
  • Announce once — the icon is aria-hidden and the hint is sr-only text 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+00A0 forbids that break and is the gap at the same time. The two obvious alternatives fail: white-space: nowrap on the wrapper does not stop the break before the wrapper, and U+2060 WORD 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 is overflow-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 a renderer for a whole MDX pipeline instead of hand-tagging outbound links.

On This Page