APG Patterns
日本語
日本語

Link

An interactive element that navigates to a resource when activated.

Demo

WAI-ARIA APG Documentation External Link (opens in new tab) Link with onClick handler 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.

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

Link.tsx
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

Example
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

See testing-strategy.md (opens in new tab) for full documentation.

Link.test.tsx
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