Detailed guide

Link Purpose Implementation Guide

View contents

Link Purpose Implementation Guide: Complete Patterns for Accessible Links

Introduction

Creating accessible links goes beyond simply avoiding “click here.” This guide covers comprehensive patterns for implementing link purpose across different contexts—from simple text links to complex card patterns, navigation menus, and icon-only links. You’ll learn techniques for meeting both WCAG 2.4.4 (Level A) and 2.4.9 (Level AAA) requirements.

Understanding how screen readers interpret links and how users navigate by link lists is essential for implementing truly accessible link patterns.

The accessible name for a link is computed in this priority order:

  1. aria-labelledby - References other elements
  2. aria-label - Direct string label
  3. Link text content - Visible text and alt text of images
  4. title attribute - Last resort (not recommended)
<!-- Priority 1: aria-labelledby -->
<span id="product-name">Product A</span>
<a href="/products/a" aria-labelledby="product-name learn-more-1">
  <span id="learn-more-1">Learn more</span>
</a>
<!-- Accessible name: "Product A Learn more" -->

<!-- Priority 2: aria-label -->
<a href="/products/a" aria-label="Learn more about Product A">
  Learn more
</a>
<!-- Accessible name: "Learn more about Product A" -->

<!-- Priority 3: Link text content -->
<a href="/products/a">View Product A details</a>
<!-- Accessible name: "View Product A details" -->

Best Practice: Use Visible Text When Possible

<!-- BEST: Visible text is descriptive -->
<a href="/pricing">View our pricing plans</a>

<!-- ACCEPTABLE: aria-label supplements short visible text -->
<a href="/pricing" aria-label="View pricing plans">
  Pricing
</a>

<!-- AVOID: title attribute for accessible name -->
<a href="/pricing" title="View pricing plans">
  Pricing
</a>
<!-- Navigation links -->
<nav aria-label="Main navigation">
  <a href="/">Home</a>
  <a href="/about">About our company</a>
  <a href="/services">Our services</a>
  <a href="/portfolio">View our portfolio</a>
  <a href="/contact">Contact us</a>
</nav>

<!-- Action links -->
<a href="/signup">Create your free account</a>
<a href="/demo">Request a product demo</a>
<a href="/subscribe">Subscribe to our newsletter</a>

<!-- Content links -->
<a href="/blog/accessibility-guide">
  Read: The Complete Guide to Web Accessibility
</a>

When context clarifies purpose, simpler link text is acceptable:

<!-- Paragraph context -->
<p>
  We offer three service tiers to meet your needs. Visit our
  <a href="/pricing">pricing page</a>
  to compare options.
</p>

<!-- List context -->
<ul>
  <li>
    <strong>Accessibility Audit</strong> - Comprehensive review of your site.
    <a href="/services/audit">Learn more</a>
  </li>
  <li>
    <strong>UX Research</strong> - User testing and analysis.
    <a href="/services/research">Learn more</a>
  </li>
</ul>

Note: While contextual links pass Level A, making all links self-descriptive (Level AAA) provides a better user experience.

<!-- Using aria-label -->
<a href="/search" class="icon-link" aria-label="Search">
  <svg aria-hidden="true" viewBox="0 0 24 24">
    <path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5z"/>
  </svg>
</a>

<!-- Using visually hidden text -->
<a href="/search" class="icon-link">
  <span class="visually-hidden">Search</span>
  <svg aria-hidden="true" viewBox="0 0 24 24">
    <path d="..."/>
  </svg>
</a>

<style>
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
</style>
<nav aria-label="Social media">
  <a href="https://twitter.com/company" aria-label="Follow us on Twitter">
    <svg aria-hidden="true"><!-- Twitter icon --></svg>
  </a>
  <a href="https://linkedin.com/company/name" aria-label="Connect on LinkedIn">
    <svg aria-hidden="true"><!-- LinkedIn icon --></svg>
  </a>
  <a href="https://github.com/company" aria-label="View our GitHub">
    <svg aria-hidden="true"><!-- GitHub icon --></svg>
  </a>
</nav>
<div class="actions">
  <a href="/edit/123" aria-label="Edit document">
    <svg aria-hidden="true"><!-- edit icon --></svg>
  </a>
  <a href="/delete/123" aria-label="Delete document">
    <svg aria-hidden="true"><!-- delete icon --></svg>
  </a>
  <a href="/download/123" aria-label="Download document">
    <svg aria-hidden="true"><!-- download icon --></svg>
  </a>
</div>
<!-- Simple card link -->
<a href="/blog/article-slug" class="card-link">
  <article class="card">
    <img src="/images/article.jpg" alt="">
    <h3>Understanding Web Accessibility</h3>
    <p>Learn the fundamentals of creating accessible websites...</p>
  </article>
</a>

<style>
.card-link {
  display: block;
  text-decoration: none;
  color: inherit;
}

.card-link:hover .card {
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}

.card-link:focus-visible {
  outline: 2px solid #007bff;
  outline-offset: 2px;
}
</style>
<article class="card">
  <img src="/images/article.jpg" alt="">
  <h3>
    <a href="/blog/accessibility" class="card-title-link">
      Understanding Web Accessibility
    </a>
  </h3>
  <p>Learn the fundamentals of creating accessible websites...</p>
  <div class="card-meta">
    <a href="/category/accessibility" class="category-link">
      Accessibility
    </a>
    <a href="/author/jane-doe" class="author-link">
      By Jane Doe
    </a>
  </div>
</article>

<style>
/* Make title link cover entire card for easy clicking */
.card {
  position: relative;
}

.card-title-link::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

/* Raise other links above the overlay */
.category-link,
.author-link {
  position: relative;
  z-index: 1;
}
</style>

“Read More” Pattern with Context

<!-- Using aria-labelledby for context -->
<article class="card">
  <h3 id="article-1-title">10 Tips for Better UX Design</h3>
  <p>Improve your designs with these practical tips...</p>
  <a href="/blog/ux-tips" aria-labelledby="article-1-title article-1-action">
    <span id="article-1-action">Read full article</span>
  </a>
</article>
<!-- Accessible name: "10 Tips for Better UX Design Read full article" -->

<!-- Using aria-label for context -->
<article class="card">
  <h3>10 Tips for Better UX Design</h3>
  <p>Improve your designs with these practical tips...</p>
  <a href="/blog/ux-tips" aria-label="Read full article: 10 Tips for Better UX Design">
    Read more
  </a>
</article>
<table>
  <caption>Product comparison</caption>
  <thead>
    <tr>
      <th scope="col">Product</th>
      <th scope="col">Price</th>
      <th scope="col">Actions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="product-a">Widget Pro</th>
      <td>$99</td>
      <td>
        <a href="/products/widget-pro" aria-labelledby="product-a view-1">
          <span id="view-1">View details</span>
        </a>
      </td>
    </tr>
    <tr>
      <th scope="row" id="product-b">Widget Basic</th>
      <td>$49</td>
      <td>
        <a href="/products/widget-basic" aria-labelledby="product-b view-2">
          <span id="view-2">View details</span>
        </a>
      </td>
    </tr>
  </tbody>
</table>
<td class="actions-cell">
  <a href="/edit/123" aria-label="Edit Widget Pro">Edit</a>
  <a href="/delete/123" aria-label="Delete Widget Pro">Delete</a>
</td>

Framework Implementations

// AccessibleLink.tsx
import React from 'react';

interface AccessibleLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
  href: string;
  /** If true, opens in new tab with proper security */
  external?: boolean;
  /** Visually hidden text to append for screen readers */
  srAppend?: string;
  children: React.ReactNode;
}

export function AccessibleLink({
  href,
  external = false,
  srAppend,
  children,
  ...props
}: AccessibleLinkProps) {
  const externalProps = external ? {
    target: '_blank',
    rel: 'noopener noreferrer',
  } : {};

  return (
    <a href={href} {...externalProps} {...props}>
      {children}
      {srAppend && (
        <span className="visually-hidden">{srAppend}</span>
      )}
      {external && (
        <span className="visually-hidden">(opens in new tab)</span>
      )}
    </a>
  );
}

// Usage
<AccessibleLink href="/pricing">
  View pricing
</AccessibleLink>

<AccessibleLink
  href="/blog/article"
  srAppend="about Web Accessibility"
>
  Read more
</AccessibleLink>

<AccessibleLink href="https://example.org" external>
  Visit Example.org
</AccessibleLink>
<!-- AccessibleLink.vue -->
<script setup lang="ts">
import { computed } from 'vue'

interface Props {
  href: string
  external?: boolean
  srAppend?: string
}

const props = withDefaults(defineProps<Props>(), {
  external: false
})

const linkProps = computed(() => {
  if (props.external) {
    return {
      target: '_blank',
      rel: 'noopener noreferrer'
    }
  }
  return {}
})
</script>

<template>
  <a :href="href" v-bind="linkProps">
    <slot />
    <span v-if="srAppend" class="visually-hidden">{{ srAppend }}</span>
    <span v-if="external" class="visually-hidden">(opens in new tab)</span>
  </a>
</template>

<style scoped>
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
</style>
// IconLink.tsx
import React from 'react';

interface IconLinkProps {
  href: string;
  label: string;
  icon: React.ReactNode;
  external?: boolean;
}

export function IconLink({ href, label, icon, external }: IconLinkProps) {
  return (
    <a
      href={href}
      className="icon-link"
      aria-label={label}
      {...(external && { target: '_blank', rel: 'noopener noreferrer' })}
    >
      {icon}
    </a>
  );
}

// Usage
<IconLink
  href="/search"
  label="Search"
  icon={<SearchIcon aria-hidden="true" />}
/>

<IconLink
  href="https://twitter.com/company"
  label="Follow us on Twitter"
  icon={<TwitterIcon aria-hidden="true" />}
  external
/>

File Downloads

<!-- Include file type and size -->
<a href="/files/annual-report-2024.pdf">
  Download Annual Report 2024 (PDF, 2.5MB)
</a>

<a href="/files/data-export.xlsx">
  Download data export (Excel, 156KB)
</a>

<a href="/files/presentation.pptx">
  Download presentation slides (PowerPoint, 5.2MB)
</a>
<!-- Indicate external destination -->
<a href="https://www.w3.org/WAI/" rel="external">
  W3C Web Accessibility Initiative
  <span class="visually-hidden">(external link)</span>
</a>

<!-- Or include in visible text -->
<a href="https://www.w3.org/WAI/" rel="external">
  Visit W3C Web Accessibility Initiative (external site)
</a>
<!-- Always indicate new tab behavior -->
<a href="/terms" target="_blank" rel="noopener">
  Terms of Service
  <span class="visually-hidden">(opens in new tab)</span>
</a>

<!-- Icon indication with accessible text -->
<a href="/privacy" target="_blank" rel="noopener">
  Privacy Policy
  <svg aria-hidden="true" class="external-icon"><!-- icon --></svg>
  <span class="visually-hidden">(opens in new tab)</span>
</a>

Automated Testing

// Playwright test for link purpose
import { test, expect } from '@playwright/test';

test.describe('Link Purpose Compliance', () => {
  test('no generic link text', async ({ page }) => {
    await page.goto('/');

    const genericPhrases = [
      'click here',
      'here',
      'more',
      'read more',
      'learn more',
      'link',
      'this page',
    ];

    const links = await page.locator('a').all();

    for (const link of links) {
      const text = await link.textContent();
      const ariaLabel = await link.getAttribute('aria-label');
      const accessibleName = ariaLabel || text?.trim().toLowerCase();

      // Check accessible name isn't generic
      for (const phrase of genericPhrases) {
        if (accessibleName === phrase) {
          const href = await link.getAttribute('href');
          throw new Error(
            `Generic link text "${accessibleName}" found for link to ${href}`
          );
        }
      }
    }
  });

  test('all links have accessible names', async ({ page }) => {
    await page.goto('/');

    const links = await page.locator('a').all();

    for (const link of links) {
      const text = await link.textContent();
      const ariaLabel = await link.getAttribute('aria-label');
      const ariaLabelledBy = await link.getAttribute('aria-labelledby');
      const title = await link.getAttribute('title');

      const hasAccessibleName =
        text?.trim() ||
        ariaLabel ||
        ariaLabelledBy ||
        title;

      if (!hasAccessibleName) {
        const href = await link.getAttribute('href');
        throw new Error(`Link to ${href} has no accessible name`);
      }
    }
  });

  test('icon links have accessible labels', async ({ page }) => {
    await page.goto('/');

    // Find links that only contain SVG or images
    const iconLinks = await page.locator('a:has(svg):not(:has-text(""))').all();

    for (const link of iconLinks) {
      const ariaLabel = await link.getAttribute('aria-label');
      const hiddenText = await link.locator('.visually-hidden').textContent();

      if (!ariaLabel && !hiddenText) {
        const href = await link.getAttribute('href');
        throw new Error(`Icon link to ${href} has no accessible label`);
      }
    }
  });
});

Manual Testing Checklist

  1. Link List Navigation:

    • Use screen reader to list all links
    • Each link should make sense in isolation
    • No duplicate generic text
  2. Context Verification:

    • For contextual links, verify context is programmatically associated
    • Parent paragraph, list item, or table cell
  3. Icon Link Verification:

    • Tab to each icon link
    • Verify screen reader announces purpose
    • Visual focus indicator present

Common Mistakes and Fixes

<!-- WRONG -->
<p>Click <a href="/signup">here</a> to sign up.</p>

<!-- CORRECT -->
<p><a href="/signup">Sign up for a free account</a></p>

Mistake 2: Duplicate “Read More”

<!-- WRONG -->
<a href="/article-1">Read more</a>
<a href="/article-2">Read more</a>

<!-- CORRECT -->
<a href="/article-1" aria-label="Read more about Article Title 1">Read more</a>
<a href="/article-2" aria-label="Read more about Article Title 2">Read more</a>
<!-- WRONG -->
<a href="/home"><img src="logo.png"></a>

<!-- CORRECT -->
<a href="/home"><img src="logo.png" alt="Company Name - Go to homepage"></a>

Summary Checklist

  • [ ] All links have descriptive accessible names
  • [ ] No “click here,” “here,” or “read more” without context
  • [ ] Icon-only links have aria-label or visually hidden text
  • [ ] External links indicate they open in new context
  • [ ] File downloads include format and size
  • [ ] Card patterns have accessible link structure
  • [ ] Table action links include row context
  • [ ] Duplicate link texts have unique accessible names
  • [ ] Links tested with screen reader link list navigation

References

  1. W3C - WCAG 2.2 SC 2.4.4 Link Purpose (In Context)
  2. W3C - WCAG 2.2 SC 2.4.9 Link Purpose (Link Only)
  3. W3C - Technique H30: Providing link text
  4. W3C - Technique ARIA8: Using aria-label
  5. WebAIM - Links and Hypertext

Related articles

Related version

Introduction

Link Purpose Explained

Link purpose describes where a link leads and what happens when users click it

Category hub

Hub

Wcag Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities