# An Anchor Without href Is Not a Link

**NEVER** · **ID:** `interactions-anchor-without-href` · **Category:** interactions
**Source:** [RAMS](https://www.rams.ai/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-anchor-without-href

> NEVER: Render an `<a>` without `href` and drive it from `onClick` alone. `href` is what MAKES it a link: without it the browser exposes the element as `generic` — no link role, not focusable, not in the tab order, absent from the screen reader's links list, and mouse-only. Two branches: if it NAVIGATES, give it a real `href` (you get focus, the link role, Enter, middle-click, Cmd-click, "Copy link address" for free); if it performs an ACTION, it was never a link — use `<button>`.

An <a> with only an onClick has no link role, no focus, and no place in the tab order

Rams files this Critical, and the reason is that it does not look like a bug. `<a onClick={() => navigate("/profile")}>View profile</a>` reads as correct semantic HTML and passes a naive "did you use the right element?" check — you did reach for the native element. But `href` is not decoration; it is what makes the element a link. Without it the browser exposes an `<a>` as a `generic`: no link role, not focusable, not in the tab order, and absent from the screen reader's list of links, so a keyboard user tabs straight past it and a screen-reader user never learns it exists. It is mouse-only, and it is extremely common in React. Note this is the INVERSE of three principles already in this corpus, not a duplicate of any of them: `interactions-rams-semantic-handlers` covers a non-interactive element pretending to be interactive (`<div onClick>`), `content-semantics-first` says do not reach for `role="button"` when a button exists, and `interactions-rams-keyboard-handlers` covers onClick with no onKeyDown. Here you reached for the native element, and it still fails. The fix depends on intent, and there are exactly two branches. If it navigates, give it a real `href` — the browser then hands you focus, the link role, Enter activation, middle-click and Cmd-click to open in a new tab, and "Copy link address", none of which an onClick handler can synthesise. If it performs an action, it was never a link: use `<button>`.

## Rule snippet

```tsx
// no: role-less, unfocusable, mouse-only
<a onClick={() => navigate("/profile")}>View profile</a>
// yes
<a href="/profile">View profile</a>
```

## Bad — do not do this

`interactions-anchor-without-href-bad`

```tsx
import { useState } from 'react';

/**
 * Bad: an `<a>` with no `href`, activated only by onClick.
 *
 * Without `href` the element has no link role, is not focusable, is not in the
 * tab order, and never appears in a screen reader's list of links. The DOM calls
 * it a `generic`. It looks like correct semantic HTML — which is exactly why it
 * survives review.
 *
 * The strip below is live: only elements the browser actually gives focus to can
 * light up. Tab through the row and watch focus jump straight from Home to
 * Settings, right over "View profile".
 */
export function AnchorWithoutHrefBad() {
  const [focused, setFocused] = useState<string | null>(null);
  const [clicked, setClicked] = useState(false);

  return (
    <div className="w-full space-y-4">
      <nav
        aria-label="Account (broken)"
        className="flex items-center gap-4 rounded-lg border border-border bg-card p-4"
      >
        <button
          onFocus={() => setFocused('home')}
          onBlur={() => setFocused(null)}
          className="rounded-md px-2 py-1 text-sm text-foreground underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Home
        </button>

        {/* No href. Mouse-clickable, invisible to Tab, announced as plain text. */}
        {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
        <a
          onClick={() => setClicked(true)}
          className="cursor-pointer px-2 py-1 text-sm text-foreground underline"
        >
          View profile
        </a>

        <button
          onFocus={() => setFocused('settings')}
          onBlur={() => setFocused(null)}
          className="rounded-md px-2 py-1 text-sm text-foreground underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Settings
        </button>
      </nav>

      {/* Live tab-order strip */}
      <div className="rounded-lg border border-border bg-muted p-3">
        <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
          Tab order
        </p>
        <ol className="flex flex-wrap items-center gap-2 text-xs">
          <li
            className={`rounded-md border px-2 py-1 ${
              focused === 'home'
                ? 'border-ring bg-card font-semibold text-foreground'
                : 'border-border text-muted-foreground'
            }`}
          >
            1. Home
          </li>
          <li className="rounded-md border border-dashed border-border px-2 py-1 text-muted-foreground line-through">
            View profile
          </li>
          <li
            className={`rounded-md border px-2 py-1 ${
              focused === 'settings'
                ? 'border-ring bg-card font-semibold text-foreground'
                : 'border-border text-muted-foreground'
            }`}
          >
            2. Settings
          </li>
        </ol>
      </div>

      <p className="text-xs text-error">
        Two tab stops, three controls. &ldquo;View profile&rdquo; can never light up above, because
        an <code>&lt;a&gt;</code> without <code>href</code> is not focusable and never enters the
        tab order &mdash; the browser exposes it as a <code>generic</code>, not a link. It works for
        a mouse{clicked ? ' (you just clicked it)' : ''} and for nobody else.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-anchor-without-href-good`

```tsx
import { useState } from 'react';

/**
 * Good: two branches, because the fix depends on intent.
 *
 * 1. Navigation — it goes somewhere, so give it a real `href`. The browser then
 *    supplies focus, the link role, Enter activation, middle-click and Cmd-click
 *    to open in a new tab, and "Copy link address" for free.
 * 2. Action — it does not go anywhere, so it was never a link. Use `<button>`.
 *
 * `href` is not decoration. It is what makes the element a link.
 */
export function AnchorWithoutHrefGood() {
  const [focused, setFocused] = useState<string | null>(null);
  const [signedOut, setSignedOut] = useState(false);

  return (
    <div className="w-full space-y-4">
      <nav
        aria-label="Account"
        className="flex items-center gap-4 rounded-lg border border-border bg-card p-4"
      >
        <a
          href="#"
          onFocus={() => setFocused('home')}
          onBlur={() => setFocused(null)}
          className="rounded-md px-2 py-1 text-sm text-foreground underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Home
        </a>

        {/* Navigation → a real href. Focusable, middle-clickable, copyable. */}
        <a
          href="#"
          onFocus={() => setFocused('profile')}
          onBlur={() => setFocused(null)}
          className="rounded-md px-2 py-1 text-sm text-foreground underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          View profile
        </a>

        {/* Action → it was never a link. A button. */}
        <button
          onClick={() => setSignedOut(true)}
          onFocus={() => setFocused('signout')}
          onBlur={() => setFocused(null)}
          className="rounded-md border border-border px-2 py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Sign out
        </button>
      </nav>

      {/* Live tab-order strip */}
      <div className="rounded-lg border border-border bg-muted p-3">
        <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
          Tab order
        </p>
        <ol className="flex flex-wrap items-center gap-2 text-xs">
          {[
            { id: 'home', label: '1. Home (link)' },
            { id: 'profile', label: '2. View profile (link)' },
            { id: 'signout', label: '3. Sign out (button)' },
          ].map((stop) => (
            <li
              key={stop.id}
              className={`rounded-md border px-2 py-1 ${
                focused === stop.id
                  ? 'border-ring bg-card font-semibold text-foreground'
                  : 'border-border text-muted-foreground'
              }`}
            >
              {stop.label}
            </li>
          ))}
        </ol>
      </div>

      <p className="text-xs text-success">
        Three controls, three tab stops. &ldquo;View profile&rdquo; navigates, so it carries an{' '}
        <code>href</code> and is announced as a link. &ldquo;Sign out&rdquo; performs an action, so
        it is a <code>&lt;button&gt;</code> and is announced as a button.
        {signedOut ? ' Signed out.' : ''}
      </p>
    </div>
  );
}
```

## References

- [Rams review checklist](https://rams.ai/rams.md)
- [WCAG 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)
- [MDN — <a>: The Anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a)
