CSS selectors for element screenshots: a playground
To screenshot one part of a page, you point at it with a CSS selector. Type one, or pick an example, and see what it matches on this small page and what a capture would return.
Matches 1 element. A capture returns exactly that.
First match: <section id="pricing">
Pricing
$0
$19
Popular$49
| Order | Amount |
|---|---|
| 1042 | $120 |
| 1043 | $295 |
| Total | $415 |
Made by Harbor
What a capture returns
The crop is the first match at its real size on this page. Nothing you type leaves your browser.
Ten CSS selectors and what each one captures
A CSS selector is a short pattern that points at elements of a page, and an element screenshot is cropped to the first element the pattern matches. These are the ten examples of the playground, with what each matches on the specimen page and how well it survives a change to the site.
| Selector | Matches | A capture returns | How robust it is |
|---|---|---|---|
#pricing | MatchesThe pricing section1 element | A capture returnsThe whole pricing section | SturdyAn id names one element, and it often doubles as a link target, so it is rarely renamed. |
.card | MatchesAll three price cards3 elements | A capture returnsThe Free card, because it comes first | FairA class written by a person survives layout changes, but it matches every card. |
.card:nth-child(2) | MatchesThe second card in the row1 element | A capture returnsThe Pro card | FairIt names a position, not a thing. Add a card in front and it points at a different plan. |
[data-plan="pro"] | MatchesThe card whose data-plan attribute is pro1 element | A capture returnsThe Pro card | SturdyIt names the thing itself, wherever it sits in the row. |
[data-testid="chart"] | MatchesThe figure that holds the chart1 element | A capture returnsThe chart with its caption | SturdyA test id exists for tools, so nobody changes it for visual reasons. |
main > section:first-of-type | MatchesThe first section directly inside main1 element | A capture returnsThe hero | FairIt depends on the hero staying first. A banner section added above it takes its place. |
table tr:has(td.total) | MatchesThe table row that contains the total cell1 element | A capture returnsThe Total row | SturdyIt picks the row by what is inside it, so rows can be added above without breaking it. |
.card:not(.featured) | MatchesThe cards without the featured class2 elements | A capture returnsThe Free card, the first of the two | FairA fine way to exclude, but it still matches more than one element. |
.css-1q2w3e | MatchesThe chart drawing, by a generated class name1 element | A capture returnsThe bars without the caption | BrittleA build tool made this name from the styles. The next deploy can produce a different one. |
body > main > section:nth-child(3) > figure | MatchesThe figure, by its full path from the top1 element | A capture returnsThe chart with its caption | BrittleFour levels and a position. One new wrapper or one new section anywhere on the path breaks it. |
The specimen page, as HTML
The specimen page is 50 elements, and this listing is all of it. The playground runs your selector against exactly this document, so anything you can read here you can select: tags, classes, ids and attributes.
Three details are there on purpose. Only one element has an id. The chart drawing has a class that looks machine-made, css-1q2w3e. And the cards carry a data-plan attribute, the kind of hook a careful site leaves for scripts.
<body>
<header class="site-header">
<a class="logo">Harbor</a>
<nav aria-label="Main">
<a>Product</a>
<a>Pricing</a>
<a>Docs</a>
</nav>
</header>
<main>
<section class="hero">
<p class="headline">Reports that write themselves</p>
<p class="lead">Connect your data and share a live report in minutes.</p>
<a class="btn">Start free</a>
</section>
<section id="pricing">
<p class="section-title">Pricing</p>
<div class="cards">
<article class="card" data-plan="free">
<strong class="plan">Free</strong>
<p class="price">$0</p>
</article>
<article class="card featured" data-plan="pro">
<strong class="plan">Pro</strong>
<p class="price">$19</p>
<span class="badge">Popular</span>
</article>
<article class="card" data-plan="team">
<strong class="plan">Team</strong>
<p class="price">$49</p>
</article>
</div>
</section>
<section class="stats">
<figure data-testid="chart">
<svg class="css-1q2w3e" role="img" aria-label="Signups per week">
<rect class="bar" />
<rect class="bar" />
<rect class="bar" />
<rect class="bar" />
</svg>
<figcaption>Signups per week</figcaption>
</figure>
<table class="orders">
<thead>
<tr>
<th>Order</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>1042</td>
<td>$120</td>
</tr>
<tr>
<td>1043</td>
<td>$295</td>
</tr>
<tr>
<td>Total</td>
<td class="total">$415</td>
</tr>
</tbody>
</table>
</section>
</main>
<footer class="site-footer">
<p>Made by Harbor</p>
</footer>
</body>CSS selector cheat sheet for picking one element
This CSS selector cheat sheet lists the patterns that matter for pointing at one element, grouped by kind. Every example works on the specimen page, so you can paste it into the playground. Meanings and browser support are from MDN.
| Pattern | Meaning | Example on the specimen page |
|---|---|---|
| Id, class and typeEvery browser[1] | ||
#id | Id selector. The element whose id attribute has this value. | #pricingthe pricing section |
.class | Class selector. Every element that has this class. | .cardthree cards |
tag | Type selector. Every element with this tag name. | tablethe orders table |
tag.class | Compound selector. Conditions written without a space must all hold for the same element. | article.featuredthe Pro card |
| AttributeAcross browsers since July 2015[2] | ||
[attr] | Has the attribute. Every element that has the attribute, with any value. | [data-plan]three cards |
[attr="value"] | Exact value. The attribute value is exactly this. | [data-plan="team"]the Team card |
[attr^="value"] | Starts with. The attribute value begins with this. | [class^="css-"]the chart drawing |
[attr$="value"] | Ends with. The attribute value ends with this. | [class$="header"]the page header |
[attr*="value"] | Contains. The attribute value contains this somewhere. | [aria-label*="Signups"]the chart drawing |
[attr~="value"] | Has the word. The value is a list of words separated by spaces, and one of them is exactly this. | [class~="featured"]the Pro card |
| Descendant and childEvery browser[1] | ||
A B | Descendant combinator. B anywhere inside A, at any depth. | nav athree links |
A > B | Child combinator. B that is a direct child of A. | main > sectionthree sections |
| SiblingEvery browser[1] | ||
A + B | Next-sibling combinator. B that comes immediately after A, with the same parent. | figure + tablethe orders table |
A ~ B | Subsequent-sibling combinator. Every B that comes after A, with the same parent. | .hero ~ sectionpricing and stats |
| PositionAcross browsers since July 2015; the "of selector" form is newer[3] | ||
:nth-child(n) | Nth child. The element that is the nth child of its parent. Counting starts at 1 and includes siblings of every type. | .card:nth-child(2)the Pro card |
:nth-child(n of S) | Nth match. The nth child among only those siblings that match the selector S. | .card:nth-child(2 of :not(.featured))the Team card |
:first-of-type | First of its type. The first sibling with its tag name. | main > section:first-of-typethe hero |
:last-child | Last child. The element that is the last child of its parent. | tbody tr:last-childthe Total row |
| HasAcross browsers since December 2023[4] | ||
A:has(B) | Relational pseudo-class. A, when it contains at least one B. It selects the parent, not the child. | section:has(table)the stats section |
A:has(> B) | Has a direct child. A, when B is one of its direct children. | article:has(> .badge)the Pro card |
| NotAcross browsers since July 2015[6] | ||
A:not(B) | Negation pseudo-class. A, unless it also matches B. | .card:not(.featured)the Free and Team cards |
| IsAcross browsers since January 2021[5] | ||
:is(A, B) | Matches-any pseudo-class. An element that matches any selector in the list. It shortens long lists. | :is(figure, table)the chart and the table |
Is :has() safe to use?
:has() as available across browsers since December 2023.[4] It is the only selector that picks an element by what is inside it, which is what a capture often needs: the card that contains the badge, the row that contains the total. One limit: a :has() cannot be nested inside another :has().[4]Which CSS selectors are robust, and which break?
A robust selector keeps pointing at the same thing after the site changes. The scale below orders five kinds of selector from brittle to sturdy by one question: how much reason does the site have to keep this hook as it is? It is reasoning, not a measurement.
- Most brittle
Generated class names
.css-1q2w3eCSS-in-JS libraries and build tools derive these names from the styles, so a change to the styling or a new build can rename them.
- 2 of 5
Deep positional chains
body > main > section:nth-child(3) > figureEvery level and every number is a promise about the layout, and one new wrapper or sibling breaks the chain.[11]
- 3 of 5
Meaningful class names
.pricing-tableA person chose the name to describe the thing, so it survives small layout changes, but a redesign can rename it and several elements can share it.[13]
- 4 of 5
Ids
#pricingAn id is meant to name one element in the document and is often the target of links, which gives the site a reason to keep it.
- Sturdiest
Data attributes and test ids
[data-testid="chart"]They exist for scripts and tests, not for styling, so a visual redesign has no reason to touch them.[11]
“CSS and XPath are not recommended as the DOM can often change leading to non resilient tests.”
Playwright, the browser automation library, gives this advice for tests, and it carries over to screenshots: a capture that runs every night is a test of your selector. The same page calls test ids “the most resilient way of testing”.[11] Its best-practices page shows why a styling class is a weaker hook: “Should the designer change something then the class might change”.[13]
On a site you do not control you take what exists. Look first for an id, a data- attribute or an aria-label, then for a class that describes the thing, and use positions only as a last resort.
How to find a CSS selector in 20 seconds
Finding the CSS selector of an element takes six steps in the developer tools of Chrome or Firefox: inspect, climb, copy, simplify, count, check. The copied selector is a starting point, and the simplifying is what makes it last.
Climb to the element that wraps all of it
The click usually lands on something small, such as one line of text. Move up the tree, one parent at a time, until the highlight on the page covers the whole chart, card or table.
Simplify what you copied
Look at the element for an id, a data attribute or a class that describes it, and use that alone. Delete position numbers and generated class names.
#pricingbeats#app > div:nth-child(2) > section.css-x1y2z3.Check that the first match is the right one
When the count is more than 1, run
document.querySelector(".card"). It returns the first match in document order, which is the element a capture takes. Narrow the selector until that is the one you want.[7]
// 3: too many
document.querySelectorAll('.card').length
// 1: good
document.querySelectorAll('#pricing').length
// The first match: what a capture takes
document.querySelector('.card')
// SyntaxError: not valid CSS
document.querySelectorAll('.card:nth(')
// Chrome shorthand, returns an array
$$('[data-plan="pro"]')querySelectorAll returns every match in document order and throws a SyntaxError for a selector that is not valid CSS.[8] querySelector returns the first match, or null.[7] Chrome also lets you search the page tree by selector: press Control+F or Command+F in the Elements panel.[14]A copied selector is often the brittle kind
What goes wrong with element screenshots, and the fix
An element screenshot fails in six recognisable ways, and each one has a cause you can name. Find the symptom, read the cause, apply the fix. In the drawings the blue frame is the crop and the grey shapes are the page.
The selector matches nothing
Causes
- The element is added by a script after the page has loaded.
- The element is inside an iframe. An iframe holds a separate document, and a selector run on the outer page does not look into it.
- The element is inside a shadow DOM, the private tree of a web component.
document.querySelectorAlldoes not find elements in there.
Fix
Wait for the element before capturing. For an iframe, capture the
iframeelement itself, or open the address the frame loads and capture that page. For shadow DOM, use a tool whose selectors pierce open shadow roots, as Playwright does, or select the host element.[9][10][12]It captures the wrong element
Cause
- The selector matches several elements and the capture takes the first one in document order, which may be a hidden template, a mobile menu or an earlier card.
Fix
Count the matches in the console, then narrow the selector with a parent (
#pricing .card), an attribute ([data-plan="pro"]) or:has().[7]The element is cut off at the bottom
Cause
- The element is taller than the browser window, and the tool captured only the part that was on screen.
Fix
Use a tool that captures the element from the whole document and not from the visible window. If yours does not, make the window taller than the element.
A header or banner covers part of it
Cause
- A sticky header, a cookie banner or a chat bubble is drawn on top of the page, and it was sitting over the element at the moment of capture.
Fix
Hide the overlay before the capture with
display: none, by its own selector. Hiding is safer than scrolling: the overlay follows the scroll.The element is there but empty inside
Cause
- Images, a chart or a map inside the element load only when the element scrolls near the screen, or draw themselves a moment after the page loads.
Fix
Wait for something that exists only once the content has loaded, such as
#chart canvasor.map img, and add a short pause for drawing animations. Scroll through the page first if the content loads on scroll.The capture fails although the element exists
Cause
- The element is hidden (
display: none, a closed menu or tab), or it has no size because everything inside it is positioned outside its box. There is no rectangle to capture.
Fix
Open it first with a click, or select the visible element inside or around it. In the console,
getBoundingClientRect()on the element shows a width or height of 0 when this is the cause.- The element is hidden (
Selector recipes for six common targets
These six recipes cover the parts of a page people capture most: a chart, a pricing table, a single post, a map, a code block and a table. The selectors are typical patterns to adapt, sturdiest first, and the options are those of CurlShot.
A chart
- [data-testid="revenue-chart"]
- #revenue-chart
- figure:has(canvas)
Select the box around the chart, not the canvas or svg inside it, so the title and legend come along. Wait for the drawing element to exist, and stop animations so bars are not caught half grown.
- wait_for_selector=figure canvas
- reduced_motion=true
- delay=1
A pricing table
- #pricing
- [data-section="pricing"]
- section:has(.price)
The window width decides whether the cards sit side by side or stack, so set it. Hide the cookie banner and the chat bubble, which like to sit over the bottom of a pricing section.
- viewport_width=1280
- block_cookie_banners=true
- block_chats=true
A single post or comment
- #comment-4821
- [data-post-id="4821"]
- article:first-of-type
A permalink that ends in #comment-4821 points at an element with that id, so the part after the # is your selector. A double pixel density keeps small text sharp.
- device_scale_factor=2
- hide_selectors=header,.reply-form
A map
- #map
- [aria-label="Map"]
- section:has(> .map)
Map tiles are images that arrive after the page has loaded. Wait for the network to go quiet and add a pause, or the capture shows grey squares.
- wait_until=networkidle
- delay=2
A code block
- pre
- article pre:nth-of-type(2)
- pre:has(code.language-js)
An element capture shows the box of the element, so a long line that scrolls sideways inside the block is cut at the edge. Let it wrap with a line of CSS, and add padding the same way.
- styles=pre{white-space:pre-wrap;padding:24px}
- device_scale_factor=2
A full table
- #results
- table.results
- table:has(caption)
Select the table itself. When it sits in a box that scrolls, only the visible rows are painted, so remove the height limit of that box with CSS.
- styles=.table-wrap{max-height:none;overflow:visible}
- hide_selectors=header
Element screenshots with the CurlShot API
In CurlShot the option is selector. It takes one CSS selector of up to 1000 characters and returns a PNG, JPEG or WebP of the first element that matches, exactly as big as the element. The element does not have to be on the first screen, and it may be taller than the window.
There is no padding option and no scroll-into-view option. Scrolling to the element is automatic, and space around it comes from styles: padding added to the element makes its box, and so the image, bigger.
Try it without code on the element screenshot tool, or read capturing an element and waiting and timing in the docs.
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY&url=https://example.com&selector=h1&wait_for_selector=h1&hide_selectors=.cookie-bar,%23chat&device_scale_factor=2" \
--output heading.pngIn a web address # is written %23, or everything after it is dropped before the request is sent. Here %23chat is #chat.
What the selector option does, case by case
| Situation | What happens | Error |
|---|---|---|
| One element matches | An image exactly as big as the element, in page pixels times device_scale_factor. | An image |
| Several elements match | The first one in document order is captured. The others are ignored. | An image |
| Nothing matches | The capture waits up to 3 seconds for the element to appear, then fails. The request is not counted against the quota. | 422 selector_not_found |
| The element is hidden or has no size | The same error, because there is no rectangle to capture. | 422 selector_not_found |
| The selector is not valid CSS | The request is refused and names the selector. | 400 invalid_options |
| The element is below the first screen | It is scrolled into view and captured. No option is needed. | An image |
| The element is taller or wider than the window | It is captured whole, from the document and not from the window. | An image |
| The element is inside an open shadow DOM | It is found: selector, wait_for_selector and click pierce open shadow roots. hide_selectors does not. | An image |
| The element is inside an iframe | It is not found. Capture the iframe element, or the address the frame loads. | 422 selector_not_found |
| The element is very large at a high pixel density | The request fails. Lower device_scale_factor. | 413 content_too_large |
selector together with clip_*, format=pdf or a video format | The request is refused. An element capture is an image: PNG, JPEG or WebP. | 400 invalid_options |
Options that work with selector
Eight options of CurlShot change what an element capture shows. They run in a fixed order, so a click happens after the overlays are hidden, and the element is measured last.
wait_for_selectordelaystyleshide_selectorsscriptsclickselector
- wait_for_selector
- Waits until an element matching this selector is visible, for as long as
timeoutallows (30 seconds by default). - hide_selectors
- Hides every element that matches any selector in the list (up to 50) with
display: nonebefore the capture. - click
- Clicks the first element that matches this selector before the capture, to open a tab or a menu.
- styles
- Adds your CSS to the page. This is how to add padding around the element: there is no padding option.
- delay
- Waits this many seconds more, up to 30, for charts and animations that draw late.
- full_page
- With
selector, scrolls through the page first so content that loads on scroll is there, then captures only the element. - device_scale_factor
- Pixel density from 1 to 3. At 2, an element 600 pixels wide gives an image 1200 pixels wide.
- omit_background
- Keeps areas the page does not paint transparent, in PNG and WebP.
Selector and capture glossary
- CSS selector
- A short pattern that points at elements of a web page, such as #pricing or .card. It is the language stylesheets use to say which elements a rule applies to.
- Element screenshot
- A screenshot cropped to the rectangle of one element of a page, instead of the window or the whole page.
- Bounding box
- The smallest rectangle that contains an element as drawn, including its padding and border but not its margin. It is what an element screenshot crops to.
- Document order
- The order in which elements appear in the HTML source, parents before their children. The first match of a selector is the first one in this order.
- Combinator
- The sign between two parts of a selector that says how they are related: a space (inside), > (direct child), + (next sibling) or ~ (later sibling).
- Pseudo-class
- A keyword that starts with a colon and selects by state or position instead of by name, such as :first-child, :not() or :has().
- Data attribute
- An attribute whose name starts with data-, which a site adds to carry its own information. data-testid is a common one, added so tests can find an element.
- Shadow DOM
- A tree of elements attached to one element and hidden from the scripts and styles of the page around it. Web components use it to keep their insides private.
- Iframe
- An element that shows another document inside the page, such as an embedded video, map or payment form. Its content is a separate document with its own elements.
- Viewport
- The part of the page the browser window shows at one time. Its width and height are set when a capture is requested.
Questions people ask
How do I take a screenshot of one element on a web page?
Find a CSS selector for the element (its id, a data attribute or a class), then pass it to a screenshot tool that supports element capture. In CurlShot, add selector to the request, for example selector=%23pricing, and the image contains only that element.
What happens if a CSS selector matches more than one element?
An element capture takes the first match in document order, which is the element document.querySelector returns. The other matches are ignored. To pick a later one, narrow the selector with a parent, an attribute, :nth-child() or :has().
Which CSS selector is the most reliable for screenshots?
A data attribute such as [data-testid="chart"] or an id such as #pricing. Both name the thing itself and have no reason to change when the page is restyled. Generated class names like .css-1q2w3e and long chains of positions are the least reliable.
Can a CSS selector reach inside an iframe or a shadow DOM?
Not inside an iframe: its content is a separate document, so capture the iframe element or the address it loads. Inside a shadow DOM, document.querySelectorAll finds nothing, but tools built on Playwright pierce open shadow roots. CurlShot does, for selector, wait_for_selector and click.
Is :has() safe to use in a selector?
Yes in current browsers. MDN lists :has() as available across browsers since December 2023. It selects an element by what it contains, for example tr:has(td.total) for the table row with the total cell. It cannot be nested inside another :has().
How do I get the CSS selector of an element in Chrome or Firefox?
Right-click the element and choose Inspect. In the page tree, right-click the element and choose Copy > Copy selector in Chrome or Copy > CSS Selector in Firefox. Then shorten the result to the id, data attribute or class that identifies the element.
Why does my element screenshot fail although the element is on the page?
Usually the element appears after the page has loaded, sits inside an iframe, or is hidden or has no size at the moment of capture. Wait for it to be visible first. In CurlShot that is wait_for_selector with the same selector.
Sources
- [1]MDN Web Docs, CSS selectors and combinators. Checked .
- [2]MDN Web Docs, Attribute selectors. Checked .
- [3]MDN Web Docs, :nth-child(). Checked .
- [4]MDN Web Docs, :has(). Checked .
- [5]MDN Web Docs, :is(). Checked .
- [6]MDN Web Docs, :not(). Checked .
- [7]MDN Web Docs, Document: querySelector() method. Checked .
- [8]MDN Web Docs, Document: querySelectorAll() method. Checked .
- [9]MDN Web Docs, Using shadow DOM. Checked .
- [10]MDN Web Docs, HTMLIFrameElement: contentDocument property. Checked .
- [11]Playwright, Locators. Checked .
- [12]Playwright, Other locators: CSS locator. Checked .
- [13]Playwright, Best practices. Checked .
- [14]Chrome for Developers, Get started with viewing and changing the DOM. Checked .
- [15]Chrome for Developers, What's new in DevTools (Chrome 72): Copy JS path. Checked .
- [16]Chrome for Developers, Console utilities API reference. Checked .
- [17]Firefox Source Docs, Examine and edit HTML. Checked .
- [18]Firefox Source Docs, Open the Inspector. Checked .
Read next
- Field guideWhy website screenshots look wrong, and the fixesA field guide to 11 ways a website screenshot goes wrong, from blank lazy images to cookie banners and cut-off pages, with the cause and the fix for each.
- GuideVisual regression testing, explained in picturesHow screenshot comparison works, why visual tests go flaky and how to fix each cause, the tools, and a set-up checklist with a Playwright example.
- PlaybookWebsite change monitoring with screenshotsHow to get told when a web page changes: the capture and compare loop, three ways to detect a change, a noise checklist, a cron and Node.js setup, and tools.