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 Roles
| Role | Target Element | Description |
|---|---|---|
link |
<a href> or element with role=“link” |
Identifies the element as a hyperlink. Native <a href> has this role implicitly. |
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
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
- Target Element
- Link element
- Values
- true | false
- Required
- No
- Change Trigger
- Disabled state change
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 |
- This implementation uses
<span role="link">for educational purposes. For production use, prefer native<a href>elements. - Unlike buttons, the Space key does NOT activate links. This is a key distinction between the link and button roles.
- Links must have an accessible name from text content, aria-label, or aria-labelledby.
Focus Management
| Event | Behavior |
|---|---|
Native <a href> |
Focusable by default |
| Custom links | Require tabindex="0" |
| Disabled links | Use tabindex="-1" (removed from tab order) |
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 the Testing Strategy guide for details.
/**
* Link Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of Link.astro.
* Web Component behavior (Enter/click activation, Space not activating,
* IME composition, navigation, opening in a new tab, disabled state and the
* link-activate event) is covered by E2E tests in e2e/link.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Link from './Link.astro';
describe('Link (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderLink(
props: Record<string, unknown> = {},
text = 'Click here'
): Promise<Document> {
const html = await container.renderToString(Link, { props, slots: { default: text } });
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-link custom element wrapper', async () => {
const doc = await renderLink({ href: '#' });
expect(doc.querySelector('apg-link')).not.toBeNull();
});
it('renders a span with role="link"', async () => {
const doc = await renderLink({ href: '#' });
expect(doc.querySelector('span[role="link"]')).not.toBeNull();
});
it('passes href to the Web Component via data-href', async () => {
const doc = await renderLink({ href: 'https://example.com' });
expect(doc.querySelector('apg-link')?.getAttribute('data-href')).toBe('https://example.com');
});
it('passes target to the Web Component via data-target', async () => {
const doc = await renderLink({ href: 'https://example.com', target: '_blank' });
expect(doc.querySelector('apg-link')?.getAttribute('data-target')).toBe('_blank');
});
it('does not set data-target when target is not provided', async () => {
const doc = await renderLink({ href: 'https://example.com' });
expect(doc.querySelector('apg-link')?.hasAttribute('data-target')).toBe(false);
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('uses slot content as the text content', async () => {
const doc = await renderLink({ href: '#' }, 'Learn more');
expect(doc.querySelector('[role="link"]')?.textContent?.trim()).toBe('Learn more');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" by default', async () => {
const doc = await renderLink({ href: '#' });
expect(doc.querySelector('[role="link"]')?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" when disabled', async () => {
const doc = await renderLink({ href: '#', disabled: true });
expect(doc.querySelector('[role="link"]')?.getAttribute('tabindex')).toBe('-1');
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has aria-disabled="true" when disabled', async () => {
const doc = await renderLink({ href: '#', disabled: true });
expect(doc.querySelector('[role="link"]')?.getAttribute('aria-disabled')).toBe('true');
});
it('does not have aria-disabled when not disabled', async () => {
const doc = await renderLink({ href: '#' });
expect(doc.querySelector('[role="link"]')?.hasAttribute('aria-disabled')).toBe(false);
});
it('has aria-current="page" when provided', async () => {
const doc = await renderLink({ href: '#', 'aria-current': 'page' });
expect(doc.querySelector('[role="link"]')?.getAttribute('aria-current')).toBe('page');
});
it('does not have aria-current when not provided', async () => {
const doc = await renderLink({ href: '#' });
expect(doc.querySelector('[role="link"]')?.hasAttribute('aria-current')).toBe(false);
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('applies a custom class alongside apg-link on the span', async () => {
const doc = await renderLink({ href: '#', class: 'custom-class' });
expect(doc.querySelector('[role="link"]')?.className).toBe('apg-link custom-class');
});
});
});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