# Hover-Revealed Actions Need Focus Parity

**MUST** · **ID:** `interactions-hover-revealed-actions` · **Category:** interactions
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-hover-revealed-actions

> MUST: Any action revealed on hover needs the same keyboard reveal: pair `group-hover:opacity-100` with `group-focus-within:opacity-100` (and `focus-visible:opacity-100` on the control). Row actions left at `opacity-0` stay in the tab order but render invisible, so a keyboard user focuses a button they cannot see. Do NOT "fix" it by dropping them from the tab order or setting `pointer-events-none` — that trades a broken affordance for none.

Row actions hidden behind opacity-0 group-hover are invisible and unreachable for anyone using a keyboard

The pattern is everywhere: a table row or list item whose Edit and Delete buttons live behind `opacity-0 group-hover:opacity-100`, so the row stays quiet until you point at it. It is a good instinct and a broken implementation, because hover is a pointer-only state. A keyboard user Tabs into that button and sees nothing — the element is still in the tab order, still focused, but rendered at zero opacity, so the focus ring is invisible and the page appears to swallow their focus. There is no way to discover, aim, or confirm the action. The fix is one utility: `focus-within:opacity-100` on the row (plus `focus-visible:opacity-100` on the button itself), so keyboard focus reveals exactly what hover reveals. What you must not do is "fix" it by making the buttons `pointer-events-none` or removing them from the tab order — that trades a broken affordance for no affordance. Note this is the specific, most-shipped instance of interactions-keyboard-everywhere; the general rule is easy to nod at and this is where it actually breaks.

## Rule snippet

```tsx
<tr class="group">
  <td><button class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100">Edit</button></td>
</tr>
```

## Bad — do not do this

`interactions-hover-revealed-actions-bad`

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

const ROWS = [
  { id: 1, name: 'invoice-2026-01.pdf', size: '248 KB' },
  { id: 2, name: 'contract-draft.docx', size: '84 KB' },
  { id: 3, name: 'q3-report.xlsx', size: '1.2 MB' },
];

/**
 * BAD: the row actions exist only on hover.
 * `opacity-0 group-hover:opacity-100` is a pointer-only state. The Delete button is
 * still in the tab order, so a keyboard user CAN focus it — they just cannot see it,
 * or its focus ring. Focus appears to vanish into the row, and the action is undiscoverable.
 */
export function HoverRevealedActionsBad() {
  const [rows, setRows] = useState(ROWS);

  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">Try it with Tab only — no mouse.</p>

      <ul className="divide-y divide-border rounded-lg border border-border">
        {rows.map((row) => (
          <li key={row.id} className="group flex items-center justify-between gap-4 p-3">
            <div>
              <p className="text-sm text-foreground">{row.name}</p>
              <p className="text-xs text-muted-foreground">{row.size}</p>
            </div>
            <button
              type="button"
              onClick={() => setRows((prev) => prev.filter((r) => r.id !== row.id))}
              // Revealed by hover and nothing else.
              className="rounded-md border border-border px-2 py-1 text-xs text-destructive opacity-0 transition-opacity group-hover:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>

      <p className="text-xs text-destructive">
        Tab through the rows: focus lands on invisible Delete buttons. There is no ring to follow and no
        label to read — a keyboard user cannot aim, confirm, or even discover the action.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-hover-revealed-actions-good`

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

const ROWS = [
  { id: 1, name: 'invoice-2026-01.pdf', size: '248 KB' },
  { id: 2, name: 'contract-draft.docx', size: '84 KB' },
  { id: 3, name: 'q3-report.xlsx', size: '1.2 MB' },
];

/**
 * GOOD: focus parity with hover.
 * `group-focus-within:opacity-100` gives keyboard focus exactly the same reveal that
 * hover gives the pointer, and the button keeps its place in the tab order. One extra
 * utility, and the action becomes reachable for everyone.
 */
export function HoverRevealedActionsGood() {
  const [rows, setRows] = useState(ROWS);

  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">Try it with Tab only — no mouse.</p>

      <ul className="divide-y divide-border rounded-lg border border-border">
        {rows.map((row) => (
          <li key={row.id} className="group flex items-center justify-between gap-4 p-3">
            <div>
              <p className="text-sm text-foreground">{row.name}</p>
              <p className="text-xs text-muted-foreground">{row.size}</p>
            </div>
            <button
              type="button"
              onClick={() => setRows((prev) => prev.filter((r) => r.id !== row.id))}
              aria-label={`Delete ${row.name}`}
              // Hover reveals it for pointers; focus-within reveals it for keyboards.
              className="rounded-md border border-border px-2 py-1 text-xs text-destructive opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>

      <p className="text-xs text-success">
        Tab into a row and its Delete button appears with a visible focus ring, announced as
        &ldquo;Delete invoice-2026-01.pdf&rdquo;. The row still stays quiet until you point at it or focus it.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [W3C — Understanding WCAG 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)
