How to Get All Elements in a List With Puppeteer
Use page.$$() for every matching element handle or $$eval() to extract text and attributes in one call, with iframe, Shadow DOM, timing, and error guidance.
Use page.$$() to get every matching element as an array of handles. If you need values such as text or attributes, use page.$$eval() and map over the matches in the page context.
const items = await page.$$('li');
for (const item of items) {
console.log(await item.evaluate(element => element.textContent));
}
For extraction, this is shorter and avoids returning element handles:
const texts = await page.$$eval('li', elements =>
elements.map(element => element.textContent?.trim() ?? '')
);
console.log(texts);
Puppeteer documents these multi-match APIs alongside its selector and interaction APIs. The right method depends on whether you need handles for later actions, serializable data, or a wait for elements that have not appeared yet.
Choose the method by what you need
| Goal | Use | Result |
|---|---|---|
| Inspect, click, type into, or otherwise interact with each match | page.$$() |
An array of element handles |
| Extract text, attributes, classes, or data attributes | page.$$eval() |
The value returned by your callback |
| Run a broader browser-side operation | page.evaluate() |
Your function’s serializable result |
| Wait until an element is present and ready for an action | A Puppeteer locator or explicit wait | A readiness-aware interaction |
Get all matching elements with page.$$()
page.$$() is the multiple-match counterpart to page.$(). It resolves to an array. When nothing matches, the array is empty; Puppeteer does not throw merely because a selector has no matches.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
const items = await page.$$('li');
console.log(`Found ${items.length} list items`);
for (const [index, item] of items.entries()) {
const text = await item.evaluate(element => element.textContent?.trim() ?? '');
console.log(index, text);
}
await browser.close();
Read properties from each handle
An element handle represents a node in the browser page. Use evaluate() on each handle for a property that is not exposed directly by the handle.
const links = await page.$$('ul.products > li a');
const rows = [];
for (const link of links) {
rows.push(await link.evaluate(element => ({
text: element.textContent?.trim() ?? '',
href: element instanceof HTMLAnchorElement ? element.href : null,
ariaLabel: element.getAttribute('aria-label')
})));
}
console.log(rows);
Interact with every match
const checkboxes = await page.$$('input[type="checkbox"]');
for (const checkbox of checkboxes) {
const checked = await checkbox.evaluate(element =>
element instanceof HTMLInputElement && element.checked
);
if (!checked) {
await checkbox.click();
}
}
Handles can become stale when the page replaces their nodes. If a click or evaluation fails after a navigation or re-render, query the selector again and continue with fresh handles.
Extract all values with page.$$eval()
$$eval() finds every match, passes the resulting array to a callback that runs in the page context, and returns the callback’s result. Return only serializable data such as strings, numbers, booleans, arrays, and plain objects.
const products = await page.$$eval('.product', elements =>
elements.map(element => ({
name: element.querySelector('.name')?.textContent?.trim() ?? null,
price: element.querySelector('.price')?.textContent?.trim() ?? null,
url: element.querySelector('a')?.getAttribute('href') ?? null
}))
);
console.log(products);
Text, attributes, and classes
const data = await page.$$eval('li', elements =>
elements.map(element => ({
text: element.textContent?.trim() ?? '',
title: element.getAttribute('title'),
id: element.id || null,
classes: [...element.classList]
}))
);
Visible text only
textContent includes text from descendants that may be hidden. If you need rendered text, use innerText, while remembering that layout calculation can cost more than reading textContent.
const visibleText = await page.$$eval('li', elements =>
elements
.filter(element => {
const style = getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
})
.map(element => element.innerText.trim())
);
Return an empty array safely
const values = await page.$$eval('.optional-item', elements =>
elements.map(element => element.getAttribute('data-value')).filter(Boolean)
);
if (values.length === 0) {
console.log('No optional items were present');
}
Selectors and scope
Puppeteer accepts CSS selectors and also supports selector extensions for XPath, text, accessibility roles and names, and Shadow DOM queries. A plain CSS selector does not cross a shadow root. Open shadow roots can be traversed with Puppeteer’s deep selector syntax.
CSS examples
const allRows = await page.$$('table tbody tr');
const activeRows = await page.$$('table tbody tr[data-status="active"]');
const secondItems = await page.$$('ul > li:nth-child(2)');
Text and accessibility selectors
When a CSS selector is unstable, Puppeteer’s extended selectors can target visible text or accessibility properties. Verify the selector against the Puppeteer version installed by your project because selector behavior is version-specific.
const buttons = await page.$$('::-p-text(Load more)');
const submitters = await page.$$('::-p-aria(Submit)');
Shadow DOM
For an open shadow root, use a deep selector where supported by your Puppeteer version. Closed shadow roots cannot be queried from outside the component through normal DOM APIs.
const shadowItems = await page.$$('my-list ::-p-aria(List item)');
Elements inside an iframe
page.$$() searches the page’s main frame. It does not search the document inside an iframe. Find the target frame first, then call $$() or $$eval() on that frame.
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
const frame = page.frames().find(candidate =>
candidate.url().includes('/embedded-list')
);
if (!frame) {
throw new Error('Embedded list frame was not found');
}
const texts = await frame.$$eval('li', elements =>
elements.map(element => element.textContent?.trim() ?? '')
);
console.log(texts);
For a same-page iframe whose URL is not known, inspect page.frames() or use the frame attached to the iframe element. Cross-origin restrictions still apply to browser page scripts, but Puppeteer can query the frame through its own frame API when the frame is available.
Querying is not waiting
page.$$() and page.$$eval() query what exists at the moment they run. They do not wait for a future network response, client-side render, or user action. If the list is populated asynchronously, wait for a stable condition first.
await page.goto('https://example.com/products', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.product');
const products = await page.$$eval('.product', elements =>
elements.map(element => element.textContent?.trim() ?? '')
);
For actions, Puppeteer recommends locators because they wait for presence and the state required by the action. Use a locator when the next operation is a click or type action rather than a one-time data extraction.
const loadMore = page.getByRole('button', { name: 'Load more' });
await loadMore.click();
await page.waitForSelector('.product');
const products = await page.$$eval('.product', elements =>
elements.map(element => element.textContent?.trim() ?? '')
);
Waiting for a count or condition
If an empty list is valid but you expect it to become non-empty, wait for a condition rather than assuming the first query is final.
await page.waitForFunction(
selector => document.querySelectorAll(selector).length > 0,
{},
'.product'
);
const products = await page.$$eval('.product', elements =>
elements.map(element => element.textContent?.trim() ?? '')
);
Use page.evaluate() for broader page operations
When the operation involves several selectors, sorting, filtering, or calculations across the document, run one page-context function. Keep the return value serializable.
const summary = await page.evaluate(() => {
const elements = [...document.querySelectorAll('li')];
return {
count: elements.length,
nonEmpty: elements
.map(element => element.textContent?.trim() ?? '')
.filter(Boolean),
firstTag: elements[0]?.tagName ?? null
};
});
console.log(summary);
Use $$eval() when one selector and one mapping operation express the job clearly. Use evaluate() when the page-context logic naturally spans multiple selectors or document-level state.
Complete runnable example: paginate and collect every list item
This example waits for the first page, extracts items, clicks a “Next” button while it is enabled, and stops when the control is unavailable. Adapt selectors to the site you are automating.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com/list', { waitUntil: 'networkidle2' });
const results = [];
for (;;) {
await page.waitForSelector('ul.results');
const pageItems = await page.$$eval('ul.results > li', elements =>
elements.map(element => ({
text: element.textContent?.trim() ?? '',
href: element.querySelector('a')?.href ?? null
}))
);
results.push(...pageItems);
const next = await page.$('a.next');
if (!next) break;
const disabled = await next.evaluate(element =>
element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true' ||
element.classList.contains('disabled')
);
if (disabled) break;
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(() => null),
next.click()
]);
}
console.log(JSON.stringify(results, null, 2));
await browser.close();
Common errors and fixes
| Error or symptom | Cause | Fix |
|---|---|---|
items is empty |
The selector does not match, the list has not rendered, or the elements are inside an iframe or shadow root. | Check the selector in DevTools, wait for the render condition, and query the correct frame or shadow scope. |
Execution context was destroyed |
Navigation or a reload occurred while evaluation was running. | Await navigation, then query again after the new document is ready. |
Node is detached from document |
A framework re-render replaced the matched node. | Re-query immediately before the action, or extract values in one $$eval() call. |
| Only the first item is returned | page.$() was used instead of page.$$(). |
Use the plural method, or use $$eval() for extraction. |
| Callback result cannot be serialized | The callback returned a DOM node, handle, function, or another non-serializable object. | Map to strings, numbers, booleans, arrays, or plain objects. |
| Click times out | The element exists but is hidden, covered, disabled, or not stable. | Use a locator, wait for the required state, scroll into view, or inspect overlays and disabled attributes. |
| Selector works in DevTools but not Puppeteer | The selector relies on a different document, closed shadow root, or unsupported selector syntax for the installed version. | Confirm the frame, use Puppeteer’s documented selector syntax, and check the installed version’s API reference. |
Performance and reliability
- Prefer one
$$eval()call for extraction. It transfers one result instead of repeatedly crossing between Node.js and the browser for every handle. - Keep the callback small. The function runs in the page, so pass only serializable arguments and avoid expensive layout reads unless you need rendered geometry.
- Use a narrow selector.
ul.results > liis easier to reason about and usually cheaper than a broad selector such as*. - Control pagination and infinite scroll. Track item IDs or URLs so a repeated page cannot create duplicates or an endless loop.
- Close handles when retaining them. If a long-running job stores many element handles, release them after use and prefer immediate extraction where possible.
- Wait for the page’s real readiness condition.
networkidlealone may not mean a client-rendered list is complete; wait for a selector or count that represents the data you need. - Handle navigation explicitly. Pair a click that causes navigation with
waitForNavigation(), then query the new document.
Or skip the browser setup
If your goal is a screenshot of the page or list rather than DOM-level interaction, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one GET request. Its capture flow accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status.
See the ScreenshotNeo API documentation for all options. A minimal call is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));
You can still control full-page capture, CSS-element capture, device and viewport settings, dark mode, custom CSS and JavaScript, waits, request blocking, headers, cookies, geolocation, caching, signed links, PDFs, bulk requests, and async webhooks. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and start with 1,000 screenshots each month at no charge.
FAQ
What does Puppeteer return when there are no matches?
page.$$() resolves to an empty array. $$eval() receives an empty array, so return an appropriate empty result from your callback.
Can I use an XPath expression with $$()?
Puppeteer supports selector extensions, including XPath, but the exact syntax depends on the installed Puppeteer version. Check the current API reference for the selector form your project supports.
Should I use $$() or $$eval() for screenshots?
Use $$() when you need to interact with elements before capture. If you only need the page image, a screenshot API can avoid browser installation and automation code.
How do I get all elements after clicking “Load more”?
Click the control with a locator or handle, wait for the new items or a changed count, then run $$() or $$eval() again. Existing arrays do not update automatically.
Can $$eval() return element handles?
No. Return serializable data from the page callback. Use $$() when you need handles for later operations.


