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
eun-buttonGuidance
- 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 |
|---|---|---|---|
| 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 |
| href | string | — | The destination URL |
| target | '_blank' | '_self' | '_parent' | '_top' | '_self' | Where to open the linked URL. Opening in a new tab automatically announces it to assistive technology |
| 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 |
| 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 |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| (default) | The link content |
| icon | Replaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty |
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 |
| --link-color-visited | Sets the color of a visited link |
| --link-color-visited-hover | Sets the color of a visited link on hover |
| --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-padding | Sets padding around the link, for example to give its focus outline room to breathe. Defaults to 0, flush with the text |
| --link-background | Sets a persistent background color behind the link. Defaults to transparent |
| --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 |
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>
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) and appends "(opens in a new tab)" to the accessible
name, so screen reader users aren't caught off guard.
<eun-link href="https://example.com" target="_blank">
Read the documentation
</eun-link>
Dark background
<eun-link href="https://example.com" theme="dark">
Read the documentation
</eun-link>
Custom color
Set --link-color directly, also used as the hover blend base.
<eun-link href="https://example.com" style="--link-color: #dc1414;">
Read the documentation
</eun-link>
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>
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>
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.
document.addEventListener("eunnavigate", (event) => {
// Only intercept internal routes; let external links navigate natively.
if (!event.href.startsWith("/")) {
return;
}
event.preventDefault();
myRouter.push(event.href);
});
The framework-specific bridges below adapt to the toolbar above: pick a framework to narrow this down to just that one.
Next.js
Mount a small client-only "bridge" once, near the root layout:
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function EunomiaRouterBridge() {
const router = useRouter();
useEffect(() => {
const handleNavigate = (event) => {
if (!event.href.startsWith("/")) {
return;
}
event.preventDefault();
router.push(event.href);
};
document.addEventListener("eunnavigate", handleNavigate);
return () => document.removeEventListener("eunnavigate", handleNavigate);
}, [router]);
return null;
}
Angular
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
@Injectable({ providedIn: "root" })
export class EunomiaRouterBridge {
constructor(private router: Router) {
document.addEventListener("eunnavigate", (event) => {
if (!event.href.startsWith("/")) {
return;
}
event.preventDefault();
this.router.navigateByUrl(event.href);
});
}
}
Instantiate it once (e.g. inject it in AppComponent) so its
constructor registers the listener.
React Router
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
function useEunomiaRouterBridge() {
const navigate = useNavigate();
useEffect(() => {
const handleNavigate = (event) => {
event.preventDefault();
navigate(event.href);
};
document.addEventListener("eunnavigate", handleNavigate);
return () => document.removeEventListener("eunnavigate", handleNavigate);
}, [navigate]);
}
Vue Router
import { useRouter } from "vue-router";
import { onMounted, onUnmounted } from "vue";
export function useEunomiaRouterBridge() {
const router = useRouter();
const handleNavigate = (event) => {
event.preventDefault();
router.push(event.href);
};
onMounted(() => document.addEventListener("eunnavigate", handleNavigate));
onUnmounted(() =>
document.removeEventListener("eunnavigate", handleNavigate),
);
}
Every example above decides what to intercept, typically by checking that href is an internal, relative path. Don't intercept unconditionally: external links (a different domain, a mailto:/tel: URI, ...) must keep working as normal navigation.
Keyboard interactions
Native anchor behavior applies, nothing custom to configure:
| Key | Action |
|---|---|
Tab |
Moves focus to the link |
Enter |
Activates the focused link |
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.
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.
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)