Link
An interactive element that navigates to a resource when activated.
🤖 AI Implementation GuideDemo
Native HTML
Use Native HTML First
Before using this custom component, consider using native <a href> elements.
They provide built-in accessibility, full browser functionality, SEO benefits, and work without JavaScript.
<a href="https://example.com">Visit Example</a>
<!-- For new tab -->
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
External Link
</a>
Use custom role="link" implementations only for educational purposes or when you need
complex JavaScript-driven navigation with SPA routing.
| Feature | Native <a href> | Custom role="link" |
|---|---|---|
| Ctrl/Cmd + Click (new tab) | Built-in | Not supported |
| Right-click context menu | Full menu | Limited |
| Copy link address | Built-in | Not supported |
| Drag to bookmarks | Built-in | Not supported |
| SEO recognition | Crawled | May be ignored |
| Works without JavaScript | Yes | No |
| Screen reader announcement | Automatic | Requires ARIA |
| Focus management | Automatic | Requires tabindex |
This custom implementation is provided for educational purposes to demonstrate APG patterns. In
production, always prefer native <a href> elements.
Accessibility Features
WAI-ARIA Role
| Role | Element | Description |
|---|---|---|
link | <a href> or element with role="link" |
Identifies the element as a hyperlink. Native <a href> has this role implicitly.
|
This implementation uses <span role="link"> for educational purposes. For production
use, prefer native <a href> elements.
WAI-ARIA Properties
tabindex
Makes the custom link element focusable via keyboard navigation.
| Values | 0 (focusable) | -1 (not focusable)
|
| Required | Yes (for custom implementations) |
| Native HTML | <a href> is focusable by default
|
| Disabled State | Set to -1 to remove from tab order |
aria-disabled
Indicates the link is not interactive and cannot be activated.
| Values | true | false (or omitted)
|
| Required | No (only when disabled) |
| Effect | Prevents activation via click or Enter key |
aria-current (Optional)
Indicates the current item within a set (e.g., current page in navigation).
| Values | page | step | location | date |
time | true |
| Required | No |
| Use Case | Navigation menus, breadcrumbs, pagination |
Keyboard Support
| Key | Action |
|---|---|
| Enter | Activate the link and navigate to the target resource |
| Tab | Move focus to the next focusable element |
| Shift + Tab | Move focus to the previous focusable element |
Important: Unlike buttons, the Space key does not activate links.
This is a key distinction between the link and button roles.
Accessible Naming
Links must have an accessible name. This can be provided through:
- Text content (recommended) - The visible text inside the link
-
aria-label- Provides an invisible label for the link -
aria-labelledby- References an external element as the label
Focus Styles
This implementation provides clear focus indicators:
- Focus ring - Visible outline when focused via keyboard
- Cursor style - Pointer cursor to indicate interactivity
- Disabled appearance - Reduced opacity and not-allowed cursor when disabled
References
Source Code
---
/**
* APG Link Pattern - Astro Implementation
*
* An interactive element that navigates to a resource when activated.
* Uses role="link" with Web Components for enhanced interactivity.
*
* Note: This is a custom implementation for educational purposes.
* For production use, prefer native <a href> elements.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/link/
*/
export interface Props {
/** Link destination URL */
href?: string;
/** Link target */
target?: '_self' | '_blank';
/** Whether the link is disabled */
disabled?: boolean;
/** Additional CSS class */
class?: string;
}
const { href, target, disabled = false, class: className = '' } = Astro.props;
---
<apg-link data-href={href} data-target={target}>
<span
role="link"
tabindex={disabled ? -1 : 0}
aria-disabled={disabled ? 'true' : undefined}
class={`apg-link ${className}`.trim()}
>
<slot />
</span>
</apg-link>
<script>
class ApgLink extends HTMLElement {
private spanElement: HTMLSpanElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.spanElement = this.querySelector('span[role="link"]');
if (!this.spanElement) {
console.warn('apg-link: span element not found');
return;
}
this.spanElement.addEventListener('click', this.handleClick);
this.spanElement.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.spanElement?.removeEventListener('click', this.handleClick);
this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
this.spanElement = null;
}
private isDisabled(): boolean {
return this.spanElement?.getAttribute('aria-disabled') === 'true';
}
private navigate() {
const href = this.dataset.href;
const target = this.dataset.target;
if (!href) {
return;
}
if (target === '_blank') {
window.open(href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = href;
}
}
private handleClick = (event: MouseEvent) => {
if (this.isDisabled()) {
event.preventDefault();
return;
}
this.dispatchEvent(
new CustomEvent('link-activate', {
detail: { href: this.dataset.href, target: this.dataset.target },
bubbles: true,
})
);
this.navigate();
};
private handleKeyDown = (event: KeyboardEvent) => {
// Ignore if composing (IME input) or already handled
if (event.isComposing || event.defaultPrevented) {
return;
}
if (this.isDisabled()) {
return;
}
// Only Enter key activates link (NOT Space)
if (event.key === 'Enter') {
event.preventDefault();
this.dispatchEvent(
new CustomEvent('link-activate', {
detail: { href: this.dataset.href, target: this.dataset.target },
bubbles: true,
})
);
this.navigate();
}
};
}
if (!customElements.get('apg-link')) {
customElements.define('apg-link', ApgLink);
}
</script> Usage
---
import Link from './Link.astro';
---
<!-- Basic link -->
<Link href="https://example.com">Visit Example</Link>
<!-- Open in new tab -->
<Link href="https://example.com" target="_blank">
External Link
</Link>
<!-- Disabled link -->
<Link href="#" disabled>Unavailable Link</Link>
<!-- With custom event listener (JavaScript) -->
<Link href="#" id="interactive-link">Interactive Link</Link>
<script>
document.getElementById('interactive-link')
?.addEventListener('link-activate', (e) => {
console.log('Link activated', e.detail);
});
</script> API
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | - | Link destination URL |
target | '_self' | '_blank' | '_self' | Where to open the link |
disabled | boolean | false | Whether the link is disabled |
class | string | '' | Additional CSS classes |
Custom Events
| Event | Detail | Description |
|---|---|---|
link-activate | { href, target } | Fired when link is activated (click or Enter) |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility
requirements.
Test Categories
High Priority: APG Keyboard Interaction
Test Description Enter key Activates the link and navigates to target Space key Does NOT activate the link (links only respond to Enter) IME composing Ignores Enter key during IME input Tab navigation Tab moves focus between links Disabled Tab skip Disabled links are skipped in Tab order
Important: Unlike buttons, links are activated only by the Enter key, not Space.
This is a key distinction between the link and button roles.
High Priority: ARIA Attributes
Test Description role="link" Element has link role tabindex="0" Element is focusable via keyboard aria-disabled Set to "true" when disabled tabindex="-1" Set when disabled to remove from Tab order Accessible name Name from text content, aria-label, or aria-labelledby
High Priority: Click Behavior
Test Description Click activation Click activates the link Disabled click Disabled links ignore click events Disabled Enter Disabled links ignore Enter key
Medium Priority: Accessibility
Test Description axe violations No WCAG 2.1 AA violations (via jest-axe) disabled axe No violations in disabled state aria-label axe No violations with aria-label
Low Priority: Navigation & Props
Test Description href navigation Navigates to href on activation target="_blank" Opens in new tab with security options className Custom classes are applied data-* attributes Custom data attributes are passed through
Testing Tools
- Vitest (opens in new tab)
- Test runner for unit tests
- Testing Library (opens in new tab)
- Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab)
- Browser automation for E2E tests
- axe-core/playwright (opens in new tab)
- Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
Link.test.astro.ts /**
* Link Web Component Tests
*
* Note: These are unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Link (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgLink extends HTMLElement {
private spanElement: HTMLSpanElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.spanElement = this.querySelector('span[role="link"]');
if (!this.spanElement) {
return;
}
this.spanElement.addEventListener('click', this.handleClick);
this.spanElement.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.spanElement?.removeEventListener('click', this.handleClick);
this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
}
private handleClick = (event: MouseEvent) => {
if (this.isDisabled()) {
event.preventDefault();
return;
}
this.activate(event);
};
private handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || event.defaultPrevented) {
return;
}
if (this.isDisabled()) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.activate(event);
}
};
private activate(_event: Event) {
const href = this.dataset.href;
const target = this.dataset.target;
this.dispatchEvent(
new CustomEvent('link-activate', {
detail: { href, target },
bubbles: true,
})
);
if (href) {
if (target === '_blank') {
window.open(href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = href;
}
}
}
private isDisabled(): boolean {
return this.spanElement?.getAttribute('aria-disabled') === 'true';
}
// Expose for testing
get _spanElement() {
return this.spanElement;
}
}
function createLinkHTML(
options: {
href?: string;
target?: '_self' | '_blank';
disabled?: boolean;
ariaLabel?: string;
text?: string;
} = {}
) {
const { href = '#', target, disabled = false, ariaLabel, text = 'Click here' } = options;
const tabindex = disabled ? '-1' : '0';
const ariaDisabled = disabled ? 'aria-disabled="true"' : '';
const ariaLabelAttr = ariaLabel ? `aria-label="${ariaLabel}"` : '';
return `
<apg-link
class="apg-link"
data-href="${href}"
${target ? `data-target="${target}"` : ''}
>
<span
role="link"
tabindex="${tabindex}"
${ariaDisabled}
${ariaLabelAttr}
>
${text}
</span>
</apg-link>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
// Register custom element if not already registered
if (!customElements.get('apg-link')) {
customElements.define('apg-link', TestApgLink);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('Initial Rendering', () => {
it('renders with role="link"', async () => {
container.innerHTML = createLinkHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span).toBeTruthy();
});
it('renders with tabindex="0" by default', async () => {
container.innerHTML = createLinkHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('tabindex')).toBe('0');
});
it('renders with tabindex="-1" when disabled', async () => {
container.innerHTML = createLinkHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('tabindex')).toBe('-1');
});
it('renders with aria-disabled="true" when disabled', async () => {
container.innerHTML = createLinkHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('aria-disabled')).toBe('true');
});
it('renders with aria-label for accessible name', async () => {
container.innerHTML = createLinkHTML({ ariaLabel: 'Go to homepage' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('aria-label')).toBe('Go to homepage');
});
it('has text content as accessible name', async () => {
container.innerHTML = createLinkHTML({ text: 'Learn more' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.textContent?.trim()).toBe('Learn more');
});
});
describe('Click Interaction', () => {
it('dispatches link-activate event on click', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
span.click();
expect(activateHandler).toHaveBeenCalledTimes(1);
expect(activateHandler.mock.calls[0][0].detail.href).toBe('https://example.com');
});
it('does not dispatch event when disabled', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
span.click();
expect(activateHandler).not.toHaveBeenCalled();
});
});
describe('Keyboard Interaction', () => {
it('dispatches link-activate event on Enter key', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(enterEvent);
expect(activateHandler).toHaveBeenCalledTimes(1);
});
it('does not dispatch event on Space key', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(spaceEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when disabled (Enter key)', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when isComposing is true', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
Object.defineProperty(enterEvent, 'isComposing', { value: true });
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
});
describe('Navigation', () => {
const originalLocation = window.location;
beforeEach(() => {
// @ts-expect-error - delete window.location for mocking
delete window.location;
// @ts-expect-error - mock window.location
window.location = { ...originalLocation, href: '' };
});
afterEach(() => {
// @ts-expect-error - restore window.location
window.location = originalLocation;
});
it('navigates to href on activation', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]') as HTMLElement;
span.click();
expect(window.location.href).toBe('https://example.com');
});
it('opens in new tab when target="_blank"', async () => {
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
container.innerHTML = createLinkHTML({ href: 'https://example.com', target: '_blank' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]') as HTMLElement;
span.click();
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer'
);
});
it('does not navigate when disabled', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]') as HTMLElement;
span.click();
expect(window.location.href).toBe('');
});
});
});
Resources
-
WAI-ARIA APG: Link Pattern
(opens in new tab)
-
MDN: <a> element
(opens in new tab)
-
AI Implementation Guide (llm.md)
(opens in new tab) - ARIA specs, keyboard support, test checklist