DEV Community

BotFumble
BotFumble

Posted on

Can AI agents find links in hover dropdown menus? 6 menu variants tested with Playwright

An AI agent reading your page through Playwright doesn't see your nav the way your customers do. That "Product" dropdown a person opens by moving the mouse across the header? In the snapshot the model gets, the links inside it are missing until something hovers, and nothing marks "Product" as having children. It might be why a model asked to find your pricing or signup link reports that there isn't one.

Short answer: it depends on how the submenu is hidden. With display:none until :hover, playwright-cli find Pricing returned "No matches found" and get_by_role("link", name="Pricing") counted 0 until something hovered "Product". A visibility:hidden submenu was also missing from the role tree at load. With opacity:0 plus pointer-events:none, the link was in the tree but a normal click timed out. Of six menu variants, the one with an extra always-rendered footer link was the only one where a role search found and clicked Pricing at load.

The snapshot matters because the Playwright MCP README says the server lets "LLMs interact with web pages through structured accessibility snapshots", and playwright-cli describes itself as "run playwright mcp commands from terminal". So the snapshot below is the page as a model using those tools gets it. No LLM ran in this post. Everything here is a fixed script or a CLI command.

In the pricing-page post I wrote that a "Plans" item inside a hover menu won't match get_by_role. I hadn't shown it. This is the check.

The test page

One nav, one hover submenu, nothing else:

<html><head><title>exp4</title><style>.sub{display:none}.menu:hover .sub{display:block}</style></head><body>
<nav><ul><li class="menu"><a href="/product">Product</a><ul class="sub"><li><a href="/pricing">Pricing</a></li><li><a href="/docs">Docs</a></li></ul></li><li><a href="/blog">Blog</a></li></ul></nav></body></html>
Enter fullscreen mode Exit fullscreen mode

Save it as page.html and serve the folder with python3 -m http.server 8765.

Step 1: what playwright-cli returns

playwright-cli 0.1.22, run on 2026-10-26:

$ playwright-cli open http://localhost:8765/page.html
$ playwright-cli snapshot
- navigation [ref=e2]:
  - list [ref=e3]:
    - listitem [ref=e4]:
      - link "Product" [ref=e5] [cursor=pointer]:
        - /url: /product
    - listitem [ref=e6]:
      - link "Blog" [ref=e7] [cursor=pointer]:
        - /url: /blog
$ playwright-cli find Pricing
### Result
No matches found for "Pricing".
$ playwright-cli hover e5
$ playwright-cli find Pricing
### Result
Found 2 matches for "Pricing":
- navigation [ref=e2]:
  - list [ref=e3]:
    - listitem [ref=e4]:
      - link "Product" [ref=e5] [cursor=pointer]:
        - /url: /product
      - list [ref=e8]:
        - listitem [ref=e9]:
          - link "Pricing" [ref=e10] [cursor=pointer]:
            - /url: /pricing
        - listitem [ref=e11]:
          - link "Docs" [ref=e12] [cursor=pointer]:
            - /url: /docs
Enter fullscreen mode Exit fullscreen mode

(Output trimmed to the snapshot and find results. The only console error was a 404 for favicon.ico.)

Before the hover, nothing in the snapshot hints that "Product" has children. A model that wants pricing has to decide on its own to hover a plain link. (find printed "2 matches" after the hover; the tool's output shows one Pricing link.) "Pricing" and "Docs" exist in the HTML and are absent from what the model gets.

Step 2: the same check from Python

# exp4: is a display:none-until-hover submenu in the accessibility tree? Fixed script, not an agent.
import pathlib, datetime
from playwright.sync_api import sync_playwright
url = pathlib.Path('page.html').resolve().as_uri()
with sync_playwright() as p:
    b = p.chromium.launch(); pg = b.new_page(); pg.goto(url)
    print("== aria snapshot before hover\n" + pg.locator('body').aria_snapshot())
    print("get_by_role link Pricing count:", pg.get_by_role('link', name='Pricing').count())
    pg.hover('text=Product')
    print("== aria snapshot after hover\n" + pg.locator('body').aria_snapshot())
    print("get_by_role link Pricing count:", pg.get_by_role('link', name='Pricing').count())
    b.close()
print(datetime.datetime.now().strftime('%F %H:%M'))
Enter fullscreen mode Exit fullscreen mode

Output with Playwright 1.64.0 (Python 3.13.5), snapshots trimmed to the counts:

get_by_role link Pricing count: 0
get_by_role link Pricing count: 1
2026-10-26 20:06
Enter fullscreen mode Exit fullscreen mode

A bug of my own first: the script hard-codes page.html, and I had saved the page under a different name, so the first rerun died with net::ERR_FILE_NOT_FOUND. If you copy this, keep the file name and the script in step.

Step 3: six menu variants and what changed

I wrote a second script, exp4b.py, that builds six versions of the same nav and checks each one at load, after one Tab key press, and with a direct click on the Pricing link by role (1.5 s timeout). Chromium 156.0.8078.4. "In DOM" is a CSS selector count, a[href='/pricing'].

# exp4b: hover-only submenu vs five variants (fixes and other hiding styles). Fixed Playwright script, no LLM, no agent.
# Run: python3 exp4b.py   (writes exp4b-*.html next to itself, Chromium only)
import pathlib, datetime
from playwright.sync_api import sync_playwright

NAV = '<li class="menu">{trigger}<ul class="sub" id="sub"><li><a href="/pricing">Pricing</a></li><li><a href="/docs">Docs</a></li></ul></li><li><a href="/blog">Blog</a></li>'
LINK = '<a href="/product">Product</a>'
BTN = '<button aria-expanded="false" aria-controls="sub" onclick="const o=this.getAttribute(\'aria-expanded\')===\'true\';this.setAttribute(\'aria-expanded\',String(!o));document.getElementById(\'sub\').style.display=o?\'none\':\'block\'">Product</button>'
PAGES = {
  "1-hover-only":   ('.sub{display:none}.menu:hover .sub{display:block}', LINK, ''),
  "2-focus-within": ('.sub{display:none}.menu:hover .sub,.menu:focus-within .sub{display:block}', LINK, ''),
  "3-disclosure":   ('.sub{display:none}', BTN, ''),
  "5-visibility-hidden": ('.sub{visibility:hidden}.menu:hover .sub{visibility:visible}', LINK, ''),
  "6-opacity-0":    ('.sub{opacity:0;pointer-events:none}.menu:hover .sub{opacity:1;pointer-events:auto}', LINK, ''),
  "4-footer-link":  ('.sub{display:none}.menu:hover .sub{display:block}', LINK, '<footer><a href="/pricing">Pricing</a></footer>'),
}
here = pathlib.Path(__file__).resolve().parent
with sync_playwright() as p:
    b = p.chromium.launch()
    print("chromium", b.version)
    for name, (css, trigger, extra) in PAGES.items():
        f = here / f"exp4b-{name}.html"
        f.write_text(f'<html><head><title>{name}</title><style>{css}</style></head><body><nav><ul>{NAV.format(trigger=trigger)}</ul></nav>{extra}</body></html>')
        pg = b.new_page(); pg.goto(f.as_uri())
        role = lambda: pg.get_by_role('link', name='Pricing').count()
        print(f"\n== {name}")
        print("  css a[href='/pricing'] in DOM:", pg.locator("a[href='/pricing']").count(),
              "| get_by_role link Pricing at load:", role())
        try:
            pg.get_by_role('link', name='Pricing').first.click(timeout=1500)
            print("  click by role at load: clicked")
        except Exception as e:
            print("  click by role at load:", type(e).__name__, str(e).splitlines()[0][:70])
        pg.goto(f.as_uri())  # run-1 bug: a successful click navigated away, so reload before the keyboard check
        pg.keyboard.press('Tab')
        print("  after 1x Tab, focused:", pg.evaluate("document.activeElement.outerHTML.slice(0,40)"), "| role count:", role())
        if name == "3-disclosure":
            print("  button line before Enter:", [l.strip() for l in pg.locator('nav').aria_snapshot().splitlines() if 'button' in l])
            pg.keyboard.press('Enter')
            print("  button line after Enter: ", [l.strip() for l in pg.locator('nav').aria_snapshot().splitlines() if 'button' in l], "| role count:", role())
        pg.close()
    b.close()
print("\nrun at", datetime.datetime.now().strftime('%F %H:%M'), "Asia/Jerusalem")
Enter fullscreen mode Exit fullscreen mode

Its first run had a second bug of mine. In the footer variant the click worked, the page navigated to /pricing, and the Tab check then ran on a page that no longer had a nav. The pg.goto reload before the Tab check fixes that, and the table below is from the run after the fix.

Variant In DOM Pricing by role at load Click by role at load By role after 1x Tab
1. display:none until :hover 1 0 TimeoutError 0
2. Same plus :focus-within 1 0 TimeoutError 1
3. <button aria-expanded> that toggles the menu 1 0 TimeoutError 0 (1 after Enter)
4. Variant 1 plus a plain footer link 2 1 clicked 1
5. visibility:hidden until :hover 1 0 TimeoutError 0
6. opacity:0; pointer-events:none until :hover 1 1 TimeoutError 1

What each row shows:

  • Rows 1 and 5. The link is in the HTML and missing from the role tree at load. A keyboard user tabbing to "Product" doesn't open the menu either. (Hover was tested for row 1 in Step 2, not for row 5.)
  • Row 2. Adding .menu:focus-within .sub{display:block} opens the menu on keyboard focus. It does nothing for a snapshot taken at load.
  • Row 3. The disclosure button opened the menu on Enter. Its snapshot line was - button "Product" before and - button "Product" [expanded] after. In this Playwright version the closed state printed no expanded=false, so the closed button gives the model no hint that a menu is there.
  • Row 4. One always-rendered link made Pricing findable and clickable at load.
  • Row 6 fails the other way. The link is in the role tree from the start and is_visible() returned True, but the click timed out with <li class="menu">… intercepts pointer events repeated in the call log. A snapshot-reading tool finds a link that a normal Playwright click can't reach. (force=True or dispatch_event would skip that check; that changes what the test means, see the previous post.)

What I'd change on a real site

  1. Put the two or three links that matter (signup, pricing, docs) somewhere that is always rendered: top-level nav or footer. Row 4 was the only variant where a role search found and clicked Pricing at load.
  2. Add :focus-within to CSS hover menus. It costs one selector and opens the menu for keyboard users (row 2).
  3. If you build the menu with a button, use the W3C disclosure navigation pattern so keyboard and screen-reader users can open it. In row 3, Enter on the button opened it and the role count went to 1. The closed button still showed no hint in the snapshot, so pair it with fix 1.
  4. Avoid hiding menus with opacity:0 plus pointer-events:none alone. In row 6 the link was in the snapshot and a normal click on it timed out.

To test your own signup link: open the page with playwright-cli, run find "Sign up" before touching anything, then try get_by_role("link", name="Sign up").click(). If the find is empty or the click times out, a model using those tools has to guess an interaction first.

Limitations

  • Synthetic pages, Chromium only, one Playwright version and one playwright-cli version.
  • No LLM agent ran. I don't know how often a real agent decides to hover "Product" when the search comes back empty.
  • The link is in the raw HTML in every variant (CSS selector count 1 or 2), so an agent that reads HTML instead of the accessibility tree may behave differently. HTML-reading and screenshot-based agents were not tested.

FAQ

Can AI agents see links in hover dropdown menus?
Not at load, if the agent reads Playwright's accessibility snapshot and the submenu is hidden with display:none. playwright-cli find Pricing returned no match until "Product" was hovered. A visibility:hidden submenu was also absent from the role tree at load.

Does get_by_role find hidden menu links?
Not while they are hidden with display:none or visibility:hidden. It counted 0 at load in variants 1, 2, 3 and 5. It counted 1 after a hover (variant 1, Step 2), after Tab focus (variant 2) and after Enter on the button (variant 3).

Is opacity: 0 a safe way to hide a menu from agents?
It keeps the link in the snapshot, but with pointer-events:none a Playwright click timed out because the parent <li> intercepted pointer events.

What is the smallest fix?
One always-rendered link to the page that matters. In row 4 a footer link made Pricing findable and clickable at load.

Both scripts and the test page are above. If your browser or menu library gives a different row, put it in the comments.


I write these while building BotFumble, a tool for seeing where AI agents get stuck on real signup and checkout flows. It is not live yet. I post shorter notes from these runs on X as @VerigateAI.

Top comments (2)

Collapse
 
autenai profile image
Auten •

Nice that every row comes with a rerunnable script. One question on variant 3: at load, did the snapshot show the button as button "Product" [expanded=false]? That flag is the only cue a model gets that something is collapsed there, unlike the plain link in variant 1, so I'd be curious whether a later run with a real model in the loop shows it pressing on that hint alone.

(I'm on the Auten team, we build a computer-use tool, and hidden menus come up for agents driving a real screen too.)

Collapse
 
arcwrightai profile image
arcwrightai •

The trust question is the one nobody wants to answer. Capability is easy to demo, reliability is not.