registry:ui · General
link — the typographic lane, standing alone
A native <a> carrying the prose face's non-nav lane as its own utilities — primary ink, 4px underline offset, hover underline — so it works with no jx-pure scope in sight; inside one, the values coincide with the face's own channels (same property, same token, deterministic no-op). External detection is PATTERN-based, not origin-based: any absolute http(s) href is external — no window.location semantics dragged into SSR — and externals open a new tab with rel=noreferrer (the fleet convention; doc-link and PressButton both ship bare noreferrer, and modern browsers imply noopener from it). href, title and the external pair land AFTER the spread (the separator law): the contract is the component's, not overridable through rest props.
$npx jixoai-ui add link
https://ui.jixoai.com/r/link.json
overview
Overview
A native anchor carrying the prose face's non-nav lane as its own utilities; the href alone decides the external contract; the axes stamp a component whose paint is one ink, one offset, one hover.
Link renders one native <a> carrying the face B2 non-nav lane as
its OWN utilities — --jx-primary ink, a 4px underline offset, underline
on hover — so it stands alone outside any jx-pure scope; inside one, the values
coincide (same property, same token — a deterministic no-op, and the face never
declared a different value anyway). The external contract is PATTERN-based: any
absolute http(s) href is external — target="_blank" + rel="noreferrer", landed after the spread (the separator law) — because
window.location has no place in an SSR-safe registry component.
The suffix-icon lane is the tri-state input semantic-glyph law verbatim: undefined paints the default externalLink glyph IFF external (SSR-painted, hydration-matched), null is the explicit OFF, a snippet renders custom content — and internal links never carry the lane. The glyph is em-sized (0.8em) with the 0.2em gap owning the whole label-to-glyph distance, so the lane rides any ambient scale — which is exactly how the size axis reaches it.
A composed leaf, precisely: the markdown family maps prose links to it (grep receipt: markdown-node is the one component edge), and it composes nothing but the icon. The eight axes resolve all no-own on the anchor — size is the one CONSUMED voice (the em lanes), theme is the frozen pole (the pure-alias case: a dark island re-substitutes nothing), and six stamp-and-supply. Per-axis below; the shared grammar lives on the universal props page.
usage
Usage
Hand it an href — that alone decides the external contract. Everything else is the advisory and attribute passthrough.
<script lang="ts">
import { Link } from '@ui/link';
</script>
<div class={cx(rt.flex, rt.wFull, rt.maxWXl, rt.col, rt.gap12, rt.lkBody)}>
<p class={cx(rt.m0)}>
Same document, different lanes: read
<Link href="/docs/components/markdown.html" title="the markdown page">the markdown page</Link>
here, or leave for
<Link href="https://github.com/jixoai/ui" title="the repository">the repository</Link>
in a new tab.
</p>
<p class={cx(rt.m0, rt.inkMuted)}>
Prose composition — the offset keeps the underline off the descenders:
<Link href="https://commonmark.org" title="the CommonMark spec">CommonMark</Link>,
<Link href="https://tailwindcss.com/docs/typography-plugin" title="the Typography plugin">Tailwind Typography</Link>,
and quiet reference ink inside a muted paragraph.
</p>
</div>link
The two lanes: app routes keep same-tab default navigation (a route change, not a departure), absolute http(s) hrefs open in a new tab with rel=noreferrer. Same component, same ink, one pattern deciding the contract.
Same document, different lanes: read the markdown page here, or leave for the repository in a new tab.
Prose composition — the offset keeps the underline off the descenders: CommonMark, Tailwind Typography, and quiet reference ink inside a muted paragraph.
<script lang="ts">
import { Link } from '@ui/link';
</script>
<div class={cx(rt.flex, rt.wFull, rt.maxWXl, rt.col, rt.gap12, rt.lkBody)}>
<p class={cx(rt.m0)}>
Same document, different lanes: read
<Link href="/docs/components/markdown.html" title="the markdown page">the markdown page</Link>
here, or leave for
<Link href="https://github.com/jixoai/ui" title="the repository">the repository</Link>
in a new tab.
</p>
<p class={cx(rt.m0, rt.inkMuted)}>
Prose composition — the offset keeps the underline off the descenders:
<Link href="https://commonmark.org" title="the CommonMark spec">CommonMark</Link>,
<Link href="https://tailwindcss.com/docs/typography-plugin" title="the Typography plugin">Tailwind Typography</Link>,
and quiet reference ink inside a muted paragraph.
</p>
</div>link
The suffix-icon lane — tri-state icon, the input semantic-glyph law verbatim: undefined is NOT off. Omitted paints the default externalLink glyph IFF external (an inline-core name answering getIcon synchronously — SSR paints it, hydration matches, no flash); null turns the lane OFF; a snippet renders custom content in the lane. Internal links never carry the lane at any setting.
undefined (omitted) — the default glyph on an external: the repository
null — the lane explicitly off: a quiet external (still target=_blank; only the glyph is gone)
snippet — custom content in the lane: leaving the document
internal — the lane never ships: staying in the document
<script lang="ts">
import { Link } from '@ui/link';
</script>
<div class={cx(rt.flex, rt.wFull, rt.maxWXl, rt.col, rt.gap12, rt.lkBody)}>
{#snippet arrowGlyph()}<span aria-hidden="true" class={cx(rt.fontMono)}>→</span>{/snippet}
<p class={cx(rt.m0)}>
undefined (omitted) — the default glyph on an external:
<Link href="https://github.com/jixoai/ui">the repository</Link>
</p>
<p class={cx(rt.m0)}>
null — the lane explicitly off:
<Link href="https://github.com/jixoai/ui" icon={null}>a quiet external</Link>
(still target=_blank; only the glyph is gone)
</p>
<p class={cx(rt.m0)}>
snippet — custom content in the lane:
<Link href="https://github.com/jixoai/ui" icon={arrowGlyph}>leaving the document</Link>
</p>
<p class={cx(rt.m0, rt.inkMuted)}>
internal — the lane never ships:
<Link href="/docs/components/markdown.html">staying in the document</Link>
</p>
</div>law
Why pattern, not origin
Origin comparison (href.origin !== window.location.origin) is the familiar heuristic — and it drags window.location into a server render, couples the component to the host it mounts in, and misfires on preview hosts and subpath deployments. The pattern reading is stricter by design: ANY absolute http(s) href is a departure from the document, which is exactly what the new-tab contract means. Relative routes, anchors and non-http schemes keep default navigation.
// the whole external contract, as source:
const external = /^https?:\/\//i.test(href);
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noreferrer' : undefined}
data-jx-link={external ? 'external' : 'internal'}
/>api
API
Props extend the native HTMLAnchorAttributes minus color (the §1 collision rule) — href alone decides the external contract, icon decides only the decorative suffix lane; 15 meta props − 8 ambient axes = 7 rows, all curated.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| href * | string | — | The link target — and the whole external contract: an absolute http(s) href renders target="_blank" + rel="noreferrer" and data-jx-link="external"; relative routes, anchors and non-http schemes keep same-tab navigation. Lands AFTER the spread (the separator law) — not overridable through rest. |
| title | string | — | Advisory title, passthrough to the native attribute — also after the spread (the component's contract). |
| icon | Snippet | null | — | The suffix-icon lane, TRI-STATE (undefined ≠ off): omitted paints the default externalLink glyph IFF the link is external (inline-core — SSR-painted, hydration-matched, 0.8em); null turns the lane off; a snippet renders custom content in the data-jx-link-icon span. Internal links never carry the lane at any setting. |
| children | Snippet | — | The link label — the accessible name. Omit for attribute-only anchors; write the departure into the words, not a glyph. |
| class | string | '' | Forwarded to the anchor; consumer classes land last (tailwind-merge replaces, never stacks). |
| style | unknown | — | Inline style passthrough — composed AFTER the family's carrier stamp (the #4 seam law: never clobbered, never dropped). |
| rest | unknown (spread passthrough) | — | Every other anchor attribute passes through (Omit<HTMLAnchorAttributes, 'color'> — the §1 native-collision rule); href, title, target and rel land after the spread as the component's contract. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on link
The prose link is one ink, one offset, one hover — so six axes stamp-and-supply with zero family readers (grep receipt). The exceptions: size is CONSUMED through the em voices (the label inherits the stamp; the 0.8em glyph rescales with it), and theme is the frozen pole's pure-alias case (the ink is defineVars-resolved at the stylex :root scope — a dark island re-substitutes nothing; forced-colors is the platform exception, media-gated). The carriers stamp the anchor root, greppable in the raw SSR.
Reading the table: Property is the axis, Type is the real carrier or consumption it drives on THIS family, Default is the lane default — the named steps, number unit, and consumption are in each description.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | CONSUMED through the em voices — the §11 stamp sets the anchor's font-size and BOTH em-voiced surfaces follow: the label text (inherited — the anchor atom declares no font-size of its own) and the 0.8em suffix glyph (measured: 14px stamp → 14px anchor, 11.2px glyph; auto → the ambient scale). The underline offset is fixed 4px optical geometry and does NOT scale. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — stamps --jx-shape-effective + --jx-radius-factor-effective; no family css reads them (grep receipt: zero carrier reads in ui/link/). The anchor draws no box. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-radius-effective; no family css reads it (grep receipt: zero readers). Nothing rounds. Number unit: px. |
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | SUPPLY-ONLY — the named rung stamps data-density on the anchor; the family reads none of the re-based channels (grep receipt: zero --jx-text/--jx-hit/--jx-inset reads — the icon lane's gap is the em-relative --link-icon-gap promotion seam, not a rung channel). The stamp supplies composed/guest content. Number unit: coefficient. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — the instrument's ink is already --jx-primary (the alias), so the hue carrier adds nothing: --jx-color-effective stamps and zero family css reads it (grep receipt). Hue injection goes through the jx-hue-* utilities on the surrounding tree. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE FROZEN POLE, the pure-alias case — measured: the ink is tokens['--jx-primary'], a stylex defineVars member declared (and therefore resolved) at the stylex :root theme scope; the anchor inherits the already-substituted light value, so a scoped .dark re-substitutes NOTHING (receipt: --primary flips on the anchor, --jx-primary does not; the computed color is byte-identical). A dark island keeps the light-profile accent — consumers compose dark links inside their own dark ground. The one platform exception is forced-colors: active, which swaps the ink to the LinkText system keyword by media query, not by the axis. light and system stamp nothing — tree inheritance. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-elevation-effective; no family css reads it (grep receipt: zero shadow declarations). Text casts no shadow. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-motion-effective; no family css reads it. The hover underline is instant (no transition declared); nothing animates. Number unit: coefficient. |
Deviations, cited: the adoption is the census batch D row (the long tail —
openspec/changes/explicit-props/research/migration-census.md). The §1 collision rule:
the interface is Omit<HTMLAnchorAttributes, 'color'> — the native
color attribute cleared for the hue lane; href, title, target and rel land after the
spread as the component's contract. A composed leaf, precisely: markdown-node maps
prose links here (the one component edge), and it composes nothing but the icon.
<!-- size: CONSUMED through the em voices — the stamp sets the
anchor's font-size, the label text inherits it, and the 0.8em
suffix glyph rescales with it -->
<Link href="https://github.com/jixoai/ui" size={14}>14px label · 0.8em glyph</Link>
<!-- theme: the FROZEN pole (the pure-alias case) — the ink is
tokens['--jx-primary'], resolved at the stylex :root scope, so a
dark island re-substitutes nothing: byte-identical ink -->
<Link href="/docs.html" theme="dark">dark island, same ink</Link>link · the em voices and the frozen pole
The size pair is the consumed lane, measured: the stamped anchor computes exactly 14px and the external glyph scales to 0.8 × 14 = 11.2px with it — while the underline offset stays the fixed 4px optical calibration. The theme pair is the pure-alias frozen pole: both anchors compute the same ink (the :root-resolved --jx-primary) even though --primary itself flips on the element — the substitution site decides, and here nothing rides the flipping var.
<!-- size: CONSUMED through the em voices — the stamp sets the
anchor's font-size, the label text inherits it, and the 0.8em
suffix glyph rescales with it -->
<Link href="https://github.com/jixoai/ui" size={14}>14px label · 0.8em glyph</Link>
<!-- theme: the FROZEN pole (the pure-alias case) — the ink is
tokens['--jx-primary'], resolved at the stylex :root scope, so a
dark island re-substitutes nothing: byte-identical ink -->
<Link href="/docs.html" theme="dark">dark island, same ink</Link><script lang="ts">
import Link from '@ui/link';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (14px) applies; at 48rem+ the md case (18px)
wins — label text and the 0.8em glyph step together -->
<Link href="/docs.html" size={query({ md: 18 }, 14)}>responsive label</Link>link · query()
Media keys are min-width: below 48rem the base applies — 14px; at 48rem and wider the md case wins — 18px, label and glyph together. The number lane goes bare (results infer); string lanes take both generics. Resize across 48rem.
<script lang="ts">
import Link from '@ui/link';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (14px) applies; at 48rem+ the md case (18px)
wins — label text and the 0.8em glyph step together -->
<Link href="/docs.html" size={query({ md: 18 }, 14)}>responsive label</Link>| Token | Default |
|---|---|
| the B2 non-nav lane | --jx-primary ink · underline on hover |
| the offset | text-underline-offset: 4px |
| --link-icon-gap | 0.2em (promotion seam) |
| the optical baseline | vertical-align: -0.125em |
| forced-colors ink | LinkText |
| the hooks | data-jx-link={external|internal} · data-jx-link-icon |
a11y
Accessibility
Native anchor semantics; the accessible name is the link text (write the departure into the words, not a glyph).
Keyboard
| Key | Action |
|---|---|
| Enter | Activates the anchor — native behavior, untouched |
ARIA
| Attribute | Value | Description |
|---|---|---|
| a | native element | The platform announces the link role and its href; no extra ARIA. |
| target / rel | external pair | target=_blank + rel=noreferrer land only on detected externals; the implied noopener is the fleet convention. |
| data-jx-link | 'external' | 'internal' | Hook attribute naming which lane rendered — styling and tests read it without reverse-engineering the href. |
| data-jx-link-icon | aria-hidden lane | The suffix-icon span is decorative: the accessible name is the link text alone, and the target/rel pair — never a glyph — carries the departure semantics. |