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
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
export interface LinkProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'> {
/** Link destination URL */
href?: string;
/** Link target */
target?: '_self' | '_blank';
/** Click handler */
onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void;
/** Disabled state */
disabled?: boolean;
/** Indicates current item in a set (e.g., current page in navigation) */
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | boolean;
/** Link content */
children: React.ReactNode;
}
export const Link: React.FC<LinkProps> = ({
href,
target,
onClick,
disabled = false,
className,
children,
...spanProps
}) => {
const navigate = useCallback(() => {
if (!href) {
return;
}
if (target === '_blank') {
window.open(href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = href;
}
}, [href, target]);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
if (disabled) {
event.preventDefault();
return;
}
onClick?.(event);
// Navigate only if onClick didn't prevent the event
if (!event.defaultPrevented) {
navigate();
}
},
[disabled, onClick, navigate]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
// Ignore if composing (IME input) or already handled
if (event.nativeEvent.isComposing || event.defaultPrevented) {
return;
}
if (disabled) {
return;
}
// Only Enter key activates link (NOT Space)
if (event.key === 'Enter') {
onClick?.(event);
// Navigate only if onClick didn't prevent the event
if (!event.defaultPrevented) {
navigate();
}
}
},
[disabled, onClick, navigate]
);
return (
<span
role="link"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled ? 'true' : undefined}
className={cn('apg-link', className)}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...spanProps}
>
{children}
</span>
);
};
export default Link; Usage
import { Link } from './Link';
function App() {
return (
<div>
{/* Basic link */}
<Link href="https://example.com">Visit Example</Link>
{/* Open in new tab */}
<Link href="https://example.com" target="_blank">
External Link
</Link>
{/* With onClick handler */}
<Link onClick={(e) => console.log('Clicked', e)}>
Interactive Link
</Link>
{/* Disabled link */}
<Link href="#" disabled>
Unavailable Link
</Link>
{/* With aria-label for icon links */}
<Link href="/" aria-label="Home">
<HomeIcon />
</Link>
</div>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | - | Link destination URL |
target | '_self' | '_blank' | '_self' | Where to open the link |
onClick | (event) => void | - | Click/Enter event handler |
disabled | boolean | false | Whether the link is disabled |
children | ReactNode | - | Link content |
All other props are passed to the underlying <span> element.
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.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { Link } from './Link';
describe('Link', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG ARIA Attributes', () => {
it('has role="link" on element', () => {
render(<Link href="#">Click here</Link>);
expect(screen.getByRole('link')).toBeInTheDocument();
});
it('has tabindex="0" on element', () => {
render(<Link href="#">Click here</Link>);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('tabindex', '0');
});
it('has accessible name from text content', () => {
render(<Link href="#">Learn more</Link>);
expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument();
});
it('has accessible name from aria-label', () => {
render(
<Link href="#" aria-label="Go to homepage">
<span aria-hidden="true">→</span>
</Link>
);
expect(screen.getByRole('link', { name: 'Go to homepage' })).toBeInTheDocument();
});
it('has accessible name from aria-labelledby', () => {
render(
<>
<span id="link-label">External link</span>
<Link href="#" aria-labelledby="link-label">
Click
</Link>
</>
);
expect(screen.getByRole('link', { name: 'External link' })).toBeInTheDocument();
});
it('sets aria-disabled="true" when disabled', () => {
render(
<Link href="#" disabled>
Disabled link
</Link>
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('aria-disabled', 'true');
});
it('sets tabindex="-1" when disabled', () => {
render(
<Link href="#" disabled>
Disabled link
</Link>
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('tabindex', '-1');
});
it('does not have aria-disabled when not disabled', () => {
render(<Link href="#">Active link</Link>);
const link = screen.getByRole('link');
expect(link).not.toHaveAttribute('aria-disabled');
});
});
// 🔴 High Priority: APG Keyboard Interaction
describe('APG Keyboard Interaction', () => {
it('calls onClick on Enter key', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Link onClick={handleClick}>Click me</Link>);
const link = screen.getByRole('link');
link.focus();
await user.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick on Space key', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Link onClick={handleClick}>Click me</Link>);
const link = screen.getByRole('link');
link.focus();
await user.keyboard(' ');
expect(handleClick).not.toHaveBeenCalled();
});
it('does not call onClick when event.isComposing is true', () => {
const handleClick = vi.fn();
render(<Link onClick={handleClick}>Click me</Link>);
const link = screen.getByRole('link');
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
});
// Simulate IME composing state
Object.defineProperty(event, 'isComposing', { value: true });
link.dispatchEvent(event);
expect(handleClick).not.toHaveBeenCalled();
});
it('does not call onClick when event.defaultPrevented is true', () => {
const handleClick = vi.fn();
render(<Link onClick={handleClick}>Click me</Link>);
const link = screen.getByRole('link');
const event = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
event.preventDefault();
link.dispatchEvent(event);
expect(handleClick).not.toHaveBeenCalled();
});
it('calls onClick on click', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Link onClick={handleClick}>Click me</Link>);
await user.click(screen.getByRole('link'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled (click)', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Link onClick={handleClick} disabled>
Disabled
</Link>
);
await user.click(screen.getByRole('link'));
expect(handleClick).not.toHaveBeenCalled();
});
it('does not call onClick when disabled (Enter key)', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Link onClick={handleClick} disabled>
Disabled
</Link>
);
const link = screen.getByRole('link');
link.focus();
await user.keyboard('{Enter}');
expect(handleClick).not.toHaveBeenCalled();
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('is focusable via Tab', async () => {
const user = userEvent.setup();
render(<Link href="#">Click here</Link>);
await user.tab();
expect(screen.getByRole('link')).toHaveFocus();
});
it('is not focusable when disabled', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<Link href="#" disabled>
Disabled link
</Link>
<button>After</button>
</>
);
await user.tab();
expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('moves focus between multiple links with Tab', async () => {
const user = userEvent.setup();
render(
<>
<Link href="#">Link 1</Link>
<Link href="#">Link 2</Link>
<Link href="#">Link 3</Link>
</>
);
await user.tab();
expect(screen.getByRole('link', { name: 'Link 1' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('link', { name: 'Link 2' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('link', { name: 'Link 3' })).toHaveFocus();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Link href="#">Click here</Link>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(
<Link href="#" disabled>
Disabled link
</Link>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-label', async () => {
const { container } = render(
<Link href="#" aria-label="Go to homepage">
→
</Link>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Navigation
describe('Navigation', () => {
const originalLocation = window.location;
beforeEach(() => {
// @ts-expect-error - delete window.location for mocking
delete window.location;
window.location = { ...originalLocation, href: '' };
});
afterEach(() => {
window.location = originalLocation;
});
it('navigates to href on activation', async () => {
const user = userEvent.setup();
render(<Link href="https://example.com">Visit</Link>);
await user.click(screen.getByRole('link'));
expect(window.location.href).toBe('https://example.com');
});
it('navigates to href on Enter key', async () => {
const user = userEvent.setup();
render(<Link href="https://example.com">Visit</Link>);
const link = screen.getByRole('link');
link.focus();
await user.keyboard('{Enter}');
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);
const user = userEvent.setup();
render(
<Link href="https://example.com" target="_blank">
External
</Link>
);
await user.click(screen.getByRole('link'));
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer'
);
windowOpenSpy.mockRestore();
});
it('does not navigate when disabled', async () => {
const user = userEvent.setup();
render(
<Link href="https://example.com" disabled>
Disabled
</Link>
);
await user.click(screen.getByRole('link'));
expect(window.location.href).toBe('');
});
it('calls onClick and navigates to href', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Link href="https://example.com" onClick={handleClick}>
Visit
</Link>
);
await user.click(screen.getByRole('link'));
expect(handleClick).toHaveBeenCalledTimes(1);
expect(window.location.href).toBe('https://example.com');
});
it('does not navigate when onClick prevents default', async () => {
const handleClick = vi.fn((e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault();
});
const user = userEvent.setup();
render(
<Link href="https://example.com" onClick={handleClick}>
Visit
</Link>
);
await user.click(screen.getByRole('link'));
expect(handleClick).toHaveBeenCalledTimes(1);
expect(window.location.href).toBe('');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to element', () => {
render(
<Link href="#" className="custom-link">
Styled
</Link>
);
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-link');
});
it('passes through data-* attributes', () => {
render(
<Link href="#" data-testid="my-link" data-custom="value">
Link
</Link>
);
const link = screen.getByTestId('my-link');
expect(link).toHaveAttribute('data-custom', 'value');
});
it('sets id attribute', () => {
render(
<Link href="#" id="main-link">
Main
</Link>
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('id', 'main-link');
});
});
}); 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