Link
An interactive element that navigates to a resource when activated.
Demo
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
Required for custom implementations. Native <a href> is focusable by default. Set to -1 when disabled.
| values | 0 (focusable) | -1 (not focusable) |
| required | Yes (for custom implementations) |
aria-label
Provides an invisible label for the link when no visible text
| values | string |
| required | No |
aria-labelledby
References an external element as the label
| values | ID reference |
| required | No |
aria-current
Indicates the current item within a set (e.g., current page in navigation)
| values | page | step | location | date | time | true |
| required | No |
WAI-ARIA States
aria-disabled
| values | true | false |
| required | No (only when disabled) |
| changeTrigger | Disabled state change |
| reference | aria-disabled (opens in new tab) |
keyboard
| 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;
/** Indicates current item in a set (e.g., current page in navigation) */
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | boolean;
/** Additional CSS class */
class?: string;
}
const {
href,
target,
disabled = false,
'aria-current': ariaCurrent,
class: className = '',
} = Astro.props;
---
<apg-link data-href={href} data-target={target}>
<span
role="link"
tabindex={disabled ? -1 : 0}
aria-disabled={disabled ? 'true' : undefined}
aria-current={ariaCurrent || 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. The Link component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (role="link", tabindex)
- Keyboard interaction (Enter key activation)
- Disabled state handling
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- ARIA structure in live browser
- Keyboard activation (Enter key)
- Click interaction behavior
- Disabled state interactions
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: APG Keyboard Interaction (Unit + E2E)
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
High Priority: ARIA Attributes (Unit + E2E)
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 (Unit + E2E)
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 (Unit + E2E)
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 (Unit)
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
Low Priority: Cross-framework Consistency (E2E)
test description All frameworks have links React, Vue, Svelte, Astro all render custom link elements Same link count All frameworks render the same number of links Consistent ARIA All frameworks have consistent ARIA structure
Important: Unlike buttons, links are activated only by the Enter key,
not Space. This is a key distinction between the link and button roles.
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