# Expanded Hit Areas Must Never Collide

**NEVER** · **ID:** `interactions-hit-target-collision` · **Category:** interactions
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-hit-target-collision

> NEVER: Let two interactive elements have overlapping hit areas. This is the failure `interactions-match-hit-targets` creates: expand a sub-24px control with a pseudo-element and adjacent expanded targets overlap invisibly, so paint order decides the hit test and the element painted later steals the clicks. Grow each hit area only to the largest rect that does not collide with its neighbour's; if that lands below 24px, the layout is wrong — widen the pitch between the controls (WCAG 2.2 SC 2.5.8 measures spacing, not just size).

Grow a small control's hit area as large as it will go without overlapping the next control's

This is the rule that makes our own advice safe. "Match Visual & Hit Targets" tells you to expand a sub-24px control with a pseudo-element; "Small Touch Targets" and "No Dead Zones" push the same way. All three are about size, and none of them is about collision. Apply them literally to a row of 20px icon buttons separated by a 4px gap and each 44px pseudo-element overshoots its neighbour by roughly 20px. Nothing warns you: the pseudo-elements are invisible, the icons still look correctly spaced, and the overlap is resolved silently by paint order — the element painted later wins the hit test. So a click that lands squarely on the star icon can fire the delete button sitting next to it, and the bug only shows up in a support ticket. The fix is a ceiling on the expansion: grow each hit area until it would touch its neighbour's, then stop. If that leaves you below the minimum, the layout itself is wrong and the controls need a wider pitch — 44px of spacing rather than 44px of overlapping padding. WCAG 2.2 SC 2.5.8 encodes exactly this trade: a target under 24px still passes if a 24px circle centred on it does not intersect the circle of any other target. Spacing, not just size, is what the criterion actually measures.

## Rule snippet

```tsx
/* 20px icons, 4px apart — cap the expansion, do not overshoot into the neighbour */
.icon-btn { position: relative; }
.icon-btn::after { content: ""; position: absolute; inset: -12px -2px; }
```

## Bad — do not do this

`interactions-hit-target-collision-bad`

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

type Action = 'Star' | 'Share' | 'Delete';

const ACTIONS: Action[] = ['Star', 'Share', 'Delete'];
const GLYPH: Record<Action, string> = { Star: '★', Share: '↗', Delete: '✕' };

/**
 * Each 20px button dutifully expands to a 44px hit area with a pseudo-element,
 * exactly as the "expand small hit targets" rule says. With a 4px gap the
 * neighbouring areas overlap by ~20px, and nothing resolves that but paint order:
 * later siblings paint on top, so each button's ::after steals the right-hand
 * side of the button before it. The browser does the hit-testing here — we only
 * measure, after the fact, which icon the pointer was actually over.
 */
export function HitTargetCollisionBad() {
  const [showAreas, setShowAreas] = useState(true);
  const [log, setLog] = useState<string[]>([]);
  const iconRefs = useRef<Partial<Record<Action, HTMLSpanElement | null>>>({});

  /** Which icon is under this point? That is what the user was aiming at. */
  const aimedAt = (clientX: number, clientY: number): Action | null =>
    ACTIONS.find((action) => {
      const rect = iconRefs.current[action]?.getBoundingClientRect();
      if (!rect) return false;
      return (
        clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom
      );
    }) ?? null;

  const handleClick = (fired: Action, e: React.MouseEvent) => {
    // Keyboard activation reports 0,0 — no pointer, so no misfire to detect.
    const target = e.detail === 0 ? fired : aimedAt(e.clientX, e.clientY);
    const misfire = target !== null && target !== fired;

    setLog((prev) =>
      [
        misfire
          ? `Pointer was over "${target}" → fired ${fired.toUpperCase()}.`
          : `Fired ${fired.toUpperCase()}.`,
        ...prev,
      ].slice(0, 4),
    );
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <div className="flex items-center justify-between mb-4">
          <p className="text-xs text-muted-foreground">Row actions (20px icons, 4px gap)</p>
          <button
            onClick={() => setShowAreas((v) => !v)}
            className="text-xs rounded-md border border-border px-2 py-1 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            {showAreas ? 'Hide' : 'Show'} hit areas
          </button>
        </div>

        {/* Generous padding so the 44px areas have room to spill — and to collide. */}
        <div className="flex items-center gap-1 py-5 px-8 rounded-md bg-muted">
          {ACTIONS.map((action) => (
            <button
              key={action}
              aria-label={action}
              onClick={(e) => handleClick(action, e)}
              // A flat 44px pseudo-element, sized with no regard for what sits next to it.
              className={`relative grid size-5 place-items-center rounded-sm after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
                showAreas ? 'after:bg-destructive/15 after:ring-1 after:ring-destructive' : ''
              } ${action === 'Delete' ? 'text-destructive' : 'text-foreground'}`}
            >
              <span ref={(el) => { iconRefs.current[action] = el; }} aria-hidden className="text-xs leading-none">
                {GLYPH[action]}
              </span>
            </button>
          ))}
        </div>

        <p className="mt-4 text-xs text-muted-foreground">
          Turn the hit areas on: they overlap by ~20px. Every button got its 44px; nobody checked whether
          they fit. Click the <span className="text-foreground">right half of ✕&apos;s neighbour ↗</span> —
          Delete is painted last, so Delete&apos;s area is on top, and Delete is what fires.
        </p>

        <ul className="mt-3 space-y-1 text-xs font-mono" aria-live="polite">
          {log.length === 0 ? (
            <li className="text-muted-foreground">Click the right edge of an icon. Watch what fires.</li>
          ) : (
            log.map((entry, i) => (
              <li key={i} className={entry.includes('was over') ? 'text-destructive' : 'text-foreground'}>
                {entry}
              </li>
            ))
          )}
        </ul>
      </div>
      <p className="text-xs text-destructive mt-4">
        Overlapping hit areas — the later-painted button steals clicks aimed at its neighbour, destructively
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-hit-target-collision-good`

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

type Action = 'Star' | 'Share' | 'Delete';

const ACTIONS: Action[] = ['Star', 'Share', 'Delete'];

/**
 * Same three 20px icons, but the row is laid out on a 44px pitch: each button
 * *is* 44px wide, so its hit area is as large as it can be while abutting —
 * never intersecting — its neighbours'. WCAG 2.2 SC 2.5.8 measures exactly this.
 */
export function HitTargetCollisionGood() {
  const [showAreas, setShowAreas] = useState(true);
  const [log, setLog] = useState<string[]>([]);

  const fire = (action: Action) => {
    setLog((prev) => [`Fired ${action.toUpperCase()}.`, ...prev].slice(0, 4));
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <div className="flex items-center justify-between mb-4">
          <p className="text-xs text-muted-foreground">Row actions (20px icons, 44px pitch)</p>
          <button
            onClick={() => setShowAreas((v) => !v)}
            className="text-xs rounded-md border border-border px-2 py-1 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            {showAreas ? 'Hide' : 'Show'} hit areas
          </button>
        </div>

        <div className="flex items-center py-4 px-6 rounded-md bg-muted">
          {ACTIONS.map((action) => (
            <button
              key={action}
              aria-label={action}
              onClick={() => fire(action)}
              // The button itself is the 44px target. No pseudo-element can overshoot
              // what it doesn't have: the areas tile the row, edge to edge.
              className={`grid size-11 place-items-center rounded-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
                showAreas ? 'bg-success/15 ring-1 ring-success' : ''
              } ${action === 'Delete' ? 'text-destructive' : ''}`}
            >
              {/* The icon stays 20px — only the target grew. */}
              <span aria-hidden className="grid size-5 place-items-center text-xs leading-none">
                {action === 'Star' ? '★' : action === 'Share' ? '↗' : '✕'}
              </span>
            </button>
          ))}
        </div>

        <p className="mt-4 text-xs text-muted-foreground">
          The rects abut, they never intersect. Every click lands on the icon under the cursor. If a 44px
          pitch had not fit, the rule is to shrink the areas until they only touch — never to let them overlap.
        </p>

        <ul className="mt-3 space-y-1 text-xs font-mono" aria-live="polite">
          {log.length === 0 ? (
            <li className="text-muted-foreground">Click the star. It stars.</li>
          ) : (
            log.map((entry, i) => (
              <li key={i} className="text-foreground">
                {entry}
              </li>
            ))
          )}
        </ul>
      </div>
      <p className="text-xs text-success mt-4">
        Hit areas as large as they can be without colliding — clicks go where they look
      </p>
    </div>
  );
}
```

## References

- [jakubkrehel/skills — better-ui: surfaces.md](https://github.com/jakubkrehel/skills/blob/main/skills/better-ui/surfaces.md)
- [WCAG 2.2 SC 2.5.8 Target Size (Minimum) — spacing exception](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
