Link
A themed anchor for inline or standalone navigation, with an optional
decorative leading icon. Unlike
Dependencies
This library follows the
When to use
Use link for navigation that reads as text: inline within a sentence, a
"read more", an item in a footer list. As soon as the action should look
and feel like a button (its own background, its own visual weight,
sitting apart from body text), reach for
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/link.js";
<eun-link href="https://example.com">Read the documentation</eun-link>
Importing the file registers <eun-link> as a custom element, with
no further setup needed. It works with any framework, or none, since
it's a standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/link.js";
</script>
<eun-link href="https://example.com">Read the documentation</eun-link>
npm install @eunomia/elements
import "@eunomia/elements/link.js";
function Docs() {
return <eun-link href="https://example.com">Read the documentation</eun-link>;
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/link.js";
export function Docs() {
return <eun-link href="https://example.com">Read the documentation</eun-link>;
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/link.js";
</script>
<template>
<eun-link href="https://example.com">Read the documentation</eun-link>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/link.js";
@Component({
selector: "app-docs",
template: `<eun-link href="https://example.com"
>Read the documentation</eun-link
>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class DocsComponent {}
Alternatives
| You want… | Prefers |
|---|---|
| An action that should look and feel like a button, with its own background and visual weight | eun-button |
Guidance
- Use
eun-linkfor navigation that reads as text: inline links within a paragraph, a "read more", a footer link list - Set
target="_blank"when linking off-site or to a document, so the current context isn't lost - Add
aria-labelwhen the link text alone doesn't convey the destination out of context (a repeated "Read more" across a list of cards)
- Using
eun-linkfor an action that should look and feel like a button: see Alternatives above - Relying on the visited-state color change as the only way a user can tell a link apart from surrounding text
- Opening internal, same-site navigation in a new tab: reserve
target="_blank"for external destinations or documents
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| visitedstyle | — | ||
| aria-label | string | — | An accessible label that overrides the link content. When the link also opens in a new tab, a note announcing it is appended automatically. Ignored with a slotted anchor, which takes it instead |
| href | string | — | The destination URL |
| target | '_blank' | '_self' | '_parent' | '_top' | '_self' | Where to open the linked URL. Opening in a new tab automatically adds an external link icon at the end and announces it to assistive technology, in the current locale |
| icon | EunomiaIconName | — | An optional decorative icon displayed to the left of the link |
| theme | 'light' | 'dark' | 'light' | The text color theme. Use dark on a dark background, such as --eun-surface-inverse, where it takes the --eun-text-on-inverse colors |
| visited-style | 'auto' | 'off' | 'on' | 'auto' | Controls the visited state color. Auto follows the browser's real navigation history, off always renders as unvisited, and on always renders as visited |
| current | boolean | false | Marks the link as the current page: it renders with aria-current="page" and keeps the --link-color-current color in every state (hover, focus, visited), for example an active item in a navigation |
| block | boolean | false | Makes the link fill the width of its container, the whole row taking the hover, the focus ring and the click, even with an icon, for example an item of a vertical navigation. The label stays on one line, cut with an ellipsis when it doesn't fit |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| locale | string | — | The locale used to resolve the "opens in a new tab" announcement. Defaults to the one set with setDefaultLocale, English otherwise |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| (default) | The link content, or a native anchor carrying the href itself when href is unset |
| icon | Replaces the leading icon entirely with any content, not just an icon element, such as an icon from another set. Falls back to icon when empty. The icon is decorative : the focus ring surrounds the text only, unless the link is block |
Events
| Name | Type | Description |
|---|---|---|
| eunnavigate | NavigationEvent | Fired before following the link, so a client-side router can intercept the navigation. Cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --link-color | Sets the color of the link |
| --link-color-hover | Sets the color of the link on hover |
| --link-color-focus | Sets the color of the link on focus, the hover color by default |
| --link-focus-outline-color | Sets the color of the focus ring |
| --link-color-visited | Sets the color of a visited link |
| --link-color-visited-hover | Sets the color of a visited link on hover |
| --link-color-current | Sets the color of the current page link, kept on hover, focus and visited states |
| --link-line-height | Sets the line-height of the link. Inherits the surrounding text's line-height when unset |
| --link-text-decoration | Sets the text decoration of the link, for example none, when embedding a link inside an already distinct interactive surface such as a card |
| --link-icon-color | Sets the color of the leading icon, independently of the link text color |
| --link-icon-size | Sets the size of the leading icon, 18px by default. Also sets the font size of the icon slot, so an icon from another set sized in em follows it |
| --link-transition-duration | Sets the duration of the color, background and focus transitions. Defaults to --eun-duration-xl |
| --link-padding | Sets padding around the link, for example to give its focus outline room to breathe. Defaults to 0, flush with the text |
| --link-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --link-padding is set |
| --link-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --link-padding is set |
| --link-background | Sets a persistent background color behind the link. Defaults to transparent |
| --link-background-hover | Sets a background color shown while the link is hovered, falling back to --link-background when unset |
| --link-background-focus | Sets a background color shown while the link is keyboard-focused, falling back to --link-background when unset |
| --link-border-radius | Sets the corner radius of the link's own background/focus outline box |
Display
In a sentence
This library follows the
<p>
This library follows the
<eun-link href="https://open-wc.org" target="_blank"
>open-wc recommendations</eun-link
>
for building interoperable web components.
</p>
With icon
<eun-link href="https://example.com" icon="description">
Read the documentation
</eun-link>
import "@eunomia/elements/icon.js";
The icon module must be imported alongside the link for the icon to render.
Need something other than a eun-icon, or a different icon library
entirely? Slot your own content into icon instead. It replaces the
prop-driven icon entirely.
<eun-link href="https://example.com">
<svg
slot="icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"></path>
<path d="M14 2v6h6"></path>
</svg>
Read the documentation
</eun-link>
--link-icon-size sets the icon size, 18px by default. It also sets the
font size of the icon slot, so an icon component from another set
sized in em, as most are, follows it without targeting its svg:
<eun-link href="tel:+33400000000" style="--link-icon-size: 1.25rem">
<span slot="icon"><PhoneIcon /></span>
04 00 00 00 00
</eun-link>
The icon is decorative: once the link has one, the focus ring and the
focus background surround the text only, with a slotted anchor too. A
block link keeps them on the whole row instead.
A link on its own line
eun-link is inline-flex, so it sits on the text baseline like a word.
Alone in a list item with an icon taller than the text, the baseline
leaves a few pixels of descent under it. Align it to the middle, or make
it a block, to remove them:
.footer-list eun-link {
vertical-align: middle;
}
Navigation item
Set block on the links of a vertical navigation, such as a sidebar. The
link fills the width of its container, and the whole row takes the click,
the hover background and the focus ring, icon included. The ring stays
visible when the label is visually hidden, in a sidebar collapsed to icons.
The label stays on one line, cut with an ellipsis when it doesn't fit, a
slotted anchor included, and with links-ssr.css before the upgrade too.
Show the full label with a title or a tooltip if needed.
<nav class="side-nav">
<eun-link href="/" block icon="home">Dashboard</eun-link>
<eun-link href="/catalogue" block icon="inventory" current
>Catalogue</eun-link
>
<eun-link href="/settings" block icon="settings">Settings</eun-link>
</nav>
.side-nav eun-link {
--link-text-decoration: none;
--link-padding: var(--eun-space-s);
--link-background-hover: var(--eun-color-primary-alpha-10);
}
Wrapping
A link wraps like the text around it, with balanced lines. To keep one on
a single line, such as a navigation item, set white-space: nowrap on
eun-link: it reaches a slotted anchor too.
<!-- Wraps within its container -->
<eun-link href="/about">About our company</eun-link>
<!-- Stays on one line -->
<eun-link href="/about" style="white-space: nowrap">About our company</eun-link>
Line height
The link inherits the line-height of the text around it, so it never adds
or removes vertical space inside a paragraph. Override it with
--link-line-height for a standalone link that needs its own.
<eun-link href="https://example.com" style="--link-line-height: 1.2;">
Read the documentation
</eun-link>
Dark background
<eun-link href="https://example.com" theme="dark">
Read the documentation
</eun-link>
States
Visited state
Once the browser knows the destination has already been visited, the
link switches to the --eun-color-purple-700 token
(--eun-color-purple-300 on theme="dark"), a wayfinding aid to help
users track what they've already read. On hover, a visited link
darkens further (mixing in 20% black by default). Override that
separately with --link-color-visited-hover if needed.
Override the base visited color with --link-color-visited:
<eun-link href="https://example.com" style="--link-color-visited: #333;">
Read the documentation
</eun-link>
Real :visited styling only depends on the browser's actual navigation history for href, and browsers intentionally restrict which properties can even be changed on :visited (history-sniffing prevention).
Forcing the visited state on or off
By default, visited-style is set to auto, which leaves the browser's
real navigation history for href in charge, the same behavior described
above. Set it to off to always render a link as unvisited regardless of
that history, useful for a demo page where every destination happens to
already be in the visitor's own history. Set it to on to always render
as visited, for example to preview the visited look on demand.
<eun-link href="https://example.com" visited-style="off">
Always unvisited
</eun-link>
<eun-link href="https://example.com" visited-style="on">
Always visited
</eun-link>
In React and Next.js, visitedStyle="off" is serialized by the server as a lowercase visitedstyle attribute. eun-link accepts it as an alias of visited-style, so the link doesn't flash its visited color while waiting for hydration.
Current page
Set current on the link that points at the page the visitor is on, such as
the active item of a navigation. It renders aria-current="page" on the
underlying anchor and colors the link with --link-color-current (the
primary color by default). The color stays put on hover, focus and visited
states, so the active link keeps standing out whatever the visitor does.
<eun-link href="/">Home</eun-link>
<eun-link href="/catalogue" current style="--link-color-current: #dc1414;">
Catalogue
</eun-link>
<eun-link href="/contact">Contact</eun-link>
With a router, derive current from the active route:
<eun-link href={item.href} current={pathname === item.href}>
{item.label}
</eun-link>
Styling the focus state
eun-link:focus-visible never matches from outside, since the focusable
anchor lives inside the shadow root. eun-link exposes its keyboard focus on
the host as a focus-visible custom state instead, set only when the browser
would show a focus ring (never after a mouse click) and cleared on blur. Target
it with :state(focus-visible), which adds no attribute to the DOM.
/* An "active page" underline drawn on the host... */
.nav-link[current]::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 2px;
background: currentColor;
}
/* ...hidden while the link shows its own focus outline */
.nav-link:state(focus-visible)::after {
opacity: 0;
}
Turbopack's CSS parser (lightningcss) doesn't recognize :state() yet, and rejects the whole file it's in, taking every other rule in that same stylesheet down with it, not just this one. Until lightningcss adds support, ship any :state(focus-visible) rule as a runtime stylesheet instead: a plain <style> tag injected from a layout or root component, or a <link> to a CSS file served as a static asset, either of which bypasses the build-time CSS pipeline entirely.
Navigation
Opening in a new tab
Setting target="_blank" automatically adds rel="noopener noreferrer"
(preventing the new page from accessing window.opener, i.e.
tab-napping), shows an external link icon at the end of the link, and
appends "(opens in a new tab)" to the accessible name, so screen reader
users aren't caught off guard. That wording follows locale, or the
default set with setDefaultLocale, so a French page announces
"(ouvre dans un nouvel onglet)".
<eun-link href="https://example.com" target="_blank">
Read the documentation
</eun-link>
SPA navigation
This library is framework-agnostic, so eun-link can't import
next/link, Angular's RouterLink, or any other router directly.
Instead, right before following href, it dispatches a cancelable
eunnavigate event carrying the destination (event.href). An
app-level router can listen for it and call event.preventDefault()
to take over navigation itself. If nothing listens (or the listener
chooses not to intercept it), the browser performs a normal native
navigation. This is the same interception pattern next/link, React
Router's Link, and Vue Router's router-link use internally. It's
the exact same event
The event is skipped entirely for modified clicks (Ctrl/Cmd/Shift/Alt, middle-click) and for any target other than _self/unset, those always need the browser's native handling (new tab, new window, ...), exactly like every SPA router link does.
One router bridge, mounted once at the root of the app, covers every
link on every page. The
mailto:/tel: URI).
Search engines and server rendering
With href on the host, the actual <a href> only exists in the shadow
root, once the element has upgraded. Server-rendered HTML then carries no
link and no anchor text, so crawlers that don't run JavaScript never see
it. For a link that carries the site's structure (navigation, internal
pages), leave href unset and place a native anchor as a direct child
instead. It keeps the link's colors, its hover, focus and visited states,
and current. The anchor is also what takes focus, so an aria-label
goes on it. One left on eun-link is ignored in this mode, and a
console warning names it.
<eun-link current>
<a href="/guides/server-rendering/">Server-side rendering</a>
</eun-link>
import Link from "next/link";
<eun-link current={pathname === "/guides/server-rendering/"}>
<Link href="/guides/server-rendering/">Server-side rendering</Link>
</eun-link>;
In this mode, the anchor owns navigation: the link doesn't fire
eunnavigate for it, so a framework's own link component (Next's
<Link>, Angular's routerLink, Vue's <RouterLink>) handles routing
itself. current sets aria-current="page" on the anchor. target and
rel are yours to write on the anchor. href on the host remains fine
for tel:, mailto: and external links. See the
When the anchor opens in a new tab, the link shows the same end icon as
with href, and announces the new tab through the anchor's
aria-description, in the link's locale. The anchor's text and
aria-label stay as you wrote them, and an aria-description of your
own is kept as is.
<eun-link>
<a href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
open-wc recommendations
</a>
</eun-link>
import Link from "next/link";
<eun-link>
<Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
open-wc recommendations
</Link>
</eun-link>;
The aria-description is only a fallback: it's added once the element
upgrades, and some screen readers only read a description after a pause,
or leave it out of their links list. To announce the new tab everywhere,
pages without JavaScript included, write the wording inside the anchor
with the eun-new-tab-hint class. It becomes part of the anchor's
accessible name and of the server-rendered HTML, links-ssr.css hides
it visually, and the link then adds no aria-description, so the new
tab is never announced twice.
import Link from "next/link";
<eun-link>
<Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
open-wc recommendations
<span className="eun-new-tab-hint"> (opens in a new tab)</span>
</Link>
</eun-link>;
The class is hidden by links-ssr.css, so load that stylesheet, as described in the
A global reset on a
A site stylesheet resetting every anchor, such as
a { color: inherit; text-decoration: none }, also reaches the anchor
slotted into eun-link. A rule from the document always wins over the
link's own ::slotted(a) styles, whatever its specificity, so the anchor
loses its color, its underline and its hover and visited states, and
--link-color or --link-text-decoration can't bring them back. Leave
these anchors out of the reset instead:
:where(a:not(eun-link > a)) {
color: inherit;
text-decoration: none;
}
Customization
Custom color
Set --link-color directly, also used as the hover blend base.
A visited link keeps the visited color (purple by default), even when
--link-color is set. To keep your color on a visited link, either override the
visited color with --link-color-visited (see "Visited state" below), or set
visited-style="off".
<eun-link href="https://example.com" style="--link-color: #dc1414;">
Read the documentation
</eun-link>
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--link-color |
Sets the color of the link |
--link-color-hover |
Sets the color of the link on hover |
--link-color-focus |
Sets the color of the link on focus, the hover color by default |
--link-focus-outline-color |
Sets the color of the focus ring |
--link-color-visited |
Sets the color of a visited link |
--link-color-visited-hover |
Sets the color of a visited link on hover |
--link-color-current |
Sets the color of the current page link, kept on hover, focus and visited states |
--link-icon-color |
Sets the color of the leading icon, independently of the link text color |
--link-background |
Sets a persistent background color behind the link. Defaults to transparent |
--link-background-hover |
Sets a background color shown while the link is hovered, falling back to --link-background when unset |
--link-background-focus |
Sets a background color shown while the link is keyboard-focused, falling back to --link-background when unset |
--link-border-radius |
Sets the corner radius of the link's own background/focus outline box |
Custom icon color
The leading icon follows the link text color by default. Override it
independently with --link-icon-color, which applies both to the resting
and hover states.
<eun-link
href="https://example.com"
icon="description"
style="--link-icon-color: #dc1414;"
>
Read the documentation
</eun-link>
Keyboard interactions
Native anchor behavior applies, nothing custom to configure:
| Key | Action |
|---|---|
Tab |
Moves focus to the link |
Enter |
Activates the focused link |
Semantics and screen readers
href is required
Keyboard and screen reader access both come from the rendered
element being a real <a href="...">: an anchor with no href is
not part of the tab order and isn't exposed as a link. Always set
href, since a eun-link without one looks identical but is silently
unreachable by keyboard.
Aria attributes
aria-label: set when the link text alone doesn't convey the destination clearly out of context (e.g. a repeated "Read more" across a list of cards)- The leading
icon, when set, is purely decorative and hidden from assistive technology automatically (handled byeun-iconitself, always renderedaria-hidden)
Visited state
The visited-state color is a wayfinding aid (helps users track what
they've already read), not a WCAG requirement, but keep enough
contrast against the base color if overriding it via
--link-color-visited, so the distinction remains perceivable to
users who do rely on it.
Current page
current renders aria-current="page" on the underlying anchor, so a screen
reader announces it as the current page. Setting aria-current on the
eun-link host wouldn't do that, since the anchor lives inside its shadow
root. Don't rely on the --link-color-current color alone to convey the
state.
Opening in a new tab
target="_blank" doesn't itself announce anything to assistive
technology, so a screen reader user could otherwise be dropped into a
new tab with no warning. eun-link handles this automatically:
"(opens in a new tab)" is appended to the accessible name, whether it
comes from aria-label or from the slotted text content (via a
visually-hidden suffix in that case), with nothing to configure. With a
slotted anchor, the same wording goes on the anchor's
aria-description. It's announced in the link's locale, and the end
icon stays decorative.
Reduced motion
The color and text-decoration-color transition on hover/focus
respects prefers-reduced-motion: reduce: with that OS-level setting
on, the change applies instantly instead of fading.
Reference links
RGAA 4.1.2 HTML Specification: The a element MDN: Tabnabbing prevention (rel=noopener)