APG Patterns
日本語
日本語

Link

An interactive element that navigates to a resource when activated.

Demo

WAI-ARIA APG DocumentationExternal Link (opens in new tab)Disabled Link

Open demo only →

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.

FeatureNative <a href>Custom role="link"
Ctrl/Cmd + Click (new tab)Built-inNot supported
Right-click context menuFull menuLimited
Copy link addressBuilt-inNot supported
Drag to bookmarksBuilt-inNot supported
SEO recognitionCrawledMay be ignored
Works without JavaScriptYesNo
Screen reader announcementAutomaticRequires ARIA
Focus managementAutomaticRequires 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

Link.svelte
<script lang="ts">
  import type { Snippet } from 'svelte';

  interface LinkProps {
    /** 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;
    /** Click handler */
    onClick?: (event: MouseEvent | KeyboardEvent) => void;
    /** Children content (string for tests, Snippet for slots) */
    children?: string | Snippet<[]>;
    [key: string]: unknown;
  }

  let { href, target, disabled = false, onClick, children, ...restProps }: LinkProps = $props();

  function navigate() {
    if (!href) {
      return;
    }

    if (target === '_blank') {
      window.open(href, '_blank', 'noopener,noreferrer');
    } else {
      window.location.href = href;
    }
  }

  function handleClick(event: MouseEvent) {
    if (disabled) {
      event.preventDefault();
      return;
    }

    onClick?.(event);

    // Navigate only if onClick didn't prevent the event
    if (!event.defaultPrevented) {
      navigate();
    }
  }

  function handleKeyDown(event: KeyboardEvent) {
    // Ignore if composing (IME input) or already handled
    if (event.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();
      }
    }
  }
</script>

<span
  role="link"
  tabindex={disabled ? -1 : 0}
  aria-disabled={disabled ? 'true' : undefined}
  class="apg-link {restProps.class || ''}"
  onclick={handleClick}
  onkeydown={handleKeyDown}
  {...restProps}
  class:undefined={false}
  >{#if typeof children === 'string'}{children}{:else if children}{@render children()}{/if}</span
>

Usage

Example
<script>
  import Link from './Link.svelte';

  function handleClick(event) {
    console.log('Link clicked', event);
  }
</script>

<!-- 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={handleClick}>Interactive Link</Link>

<!-- Disabled link -->
<Link href="#" disabled>Unavailable Link</Link>

API

PropTypeDefaultDescription
hrefstring-Link destination URL
target'_self' | '_blank''_self'Where to open the link
onClick(event) => void-Click/Enter event handler
disabledbooleanfalseWhether the link is disabled
All other props are passed to the underlying <span> element via restProps.

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)

testdescription
Enter keyActivates the link and navigates to target
Space keyDoes NOT activate the link (links only respond to Enter)
IME composingIgnores Enter key during IME input
Tab navigationTab moves focus between links
Disabled Tab skipDisabled links are skipped in Tab order

High Priority: ARIA Attributes (Unit + E2E)

testdescription
role="link"Element has link role
tabindex="0"Element is focusable via keyboard
aria-disabledSet to "true" when disabled
tabindex="-1"Set when disabled to remove from Tab order
Accessible nameName from text content, aria-label, or aria-labelledby

High Priority: Click Behavior (Unit + E2E)

testdescription
Click activationClick activates the link
Disabled clickDisabled links ignore click events
Disabled EnterDisabled links ignore Enter key

Medium Priority: Accessibility (Unit + E2E)

testdescription
axe violationsNo WCAG 2.1 AA violations (via jest-axe)
disabled axeNo violations in disabled state
aria-label axeNo violations with aria-label

Low Priority: Navigation & Props (Unit)

testdescription
href navigationNavigates to href on activation
target="_blank"Opens in new tab with security options
classNameCustom classes are applied
data-* attributesCustom data attributes are passed through

Low Priority: Cross-framework Consistency (E2E)

testdescription
All frameworks have linksReact, Vue, Svelte, Astro all render custom link elements
Same link countAll frameworks render the same number of links
Consistent ARIAAll 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

See the Testing Strategy guide for details.

Link.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
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.svelte';

describe('Link (Svelte)', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG ARIA Attributes', () => {
    it('has role="link" on element', () => {
      render(Link, {
        props: { href: '#', children: 'Click here' },
      });
      expect(screen.getByRole('link')).toBeInTheDocument();
    });

    it('has tabindex="0" on element', () => {
      render(Link, {
        props: { href: '#', children: 'Click here' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('tabindex', '0');
    });

    it('has accessible name from text content', () => {
      render(Link, {
        props: { href: '#', children: 'Learn more' },
      });
      expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument();
    });

    it('has accessible name from aria-label', () => {
      render(Link, {
        props: { href: '#', 'aria-label': 'Go to homepage', children: '→' },
      });
      expect(screen.getByRole('link', { name: 'Go to homepage' })).toBeInTheDocument();
    });

    it('sets aria-disabled="true" when disabled', () => {
      render(Link, {
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('aria-disabled', 'true');
    });

    it('sets tabindex="-1" when disabled', () => {
      render(Link, {
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('tabindex', '-1');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(Link, {
        props: { href: '#', children: 'Active 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, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      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, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      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, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      const link = screen.getByRole('link');
      const event = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
      });
      Object.defineProperty(event, 'isComposing', { value: true });

      link.dispatchEvent(event);
      expect(handleClick).not.toHaveBeenCalled();
    });

    it('calls onClick on click', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(Link, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      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, {
        props: { onClick: handleClick, disabled: true, children: 'Disabled' },
      });

      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, {
        props: { onClick: handleClick, disabled: true, children: 'Disabled' },
      });

      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, {
        props: { href: '#', children: 'Click here' },
      });

      await user.tab();
      expect(screen.getByRole('link')).toHaveFocus();
    });

    it('is not focusable when disabled', async () => {
      const user = userEvent.setup();
      const container = document.createElement('div');
      document.body.appendChild(container);

      const beforeButton = document.createElement('button');
      beforeButton.textContent = 'Before';
      container.appendChild(beforeButton);

      render(Link, {
        target: container,
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });

      const afterButton = document.createElement('button');
      afterButton.textContent = 'After';
      container.appendChild(afterButton);

      await user.tab();
      expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();

      document.body.removeChild(container);
    });

    it('moves focus between multiple links with Tab', async () => {
      const user = userEvent.setup();
      const container = document.createElement('div');
      document.body.appendChild(container);

      render(Link, {
        target: container,
        props: { href: '#', children: 'Link 1' },
      });
      render(Link, {
        target: container,
        props: { href: '#', children: 'Link 2' },
      });
      render(Link, {
        target: container,
        props: { href: '#', children: 'Link 3' },
      });

      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();

      document.body.removeChild(container);
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(Link, {
        props: { href: '#', children: 'Click here' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(Link, {
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-label', async () => {
      const { container } = render(Link, {
        props: { href: '#', 'aria-label': 'Go to homepage', children: '→' },
      });
      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, {
        props: { href: 'https://example.com', children: 'Visit' },
      });

      await user.click(screen.getByRole('link'));
      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, {
        props: { href: 'https://example.com', target: '_blank', children: 'External' },
      });

      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, {
        props: { href: 'https://example.com', disabled: true, children: 'Disabled' },
      });

      await user.click(screen.getByRole('link'));
      expect(window.location.href).toBe('');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies class to element', () => {
      render(Link, {
        props: { href: '#', class: 'custom-link', children: 'Styled' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveClass('custom-link');
    });

    it('passes through data-* attributes', () => {
      render(Link, {
        props: { href: '#', 'data-testid': 'my-link', 'data-custom': 'value', children: 'Link' },
      });
      const link = screen.getByTestId('my-link');
      expect(link).toHaveAttribute('data-custom', 'value');
    });

    it('sets id attribute', () => {
      render(Link, {
        props: { href: '#', id: 'main-link', children: 'Main' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('id', 'main-link');
    });
  });
});

Resources