# CSS selectors for element screenshots

> Type a CSS selector, see what it matches on a specimen page and what a capture returns. With a cheat sheet, a robustness ranking and fixes.

Playground by Serghei Pogor, founder of CurlShot (https://curlshot.com/about/serghei-pogor). Published 23 October 2026. Page for people: https://curlshot.com/blog/css-selectors-for-element-screenshots

To screenshot one part of a web page, give the screenshot tool a CSS selector for that element, such as #pricing or [data-testid="chart"], and it returns an image cropped to the element. A capture takes the first match in document order, so a good selector matches exactly 1 element: check with document.querySelectorAll(selector).length in the browser console. Prefer an id or a data attribute to a generated class name or a long chain of positions. In CurlShot the option is called selector.

## Key facts

- An element capture takes the first match of the selector in document order, the same element document.querySelector returns.
- From brittle to sturdy: generated class names, deep positional chains, meaningful class names, ids, data attributes such as data-testid.
- The :has() pseudo-class, which selects an element by what it contains, has worked across browsers since December 2023 according to MDN.
- A selector run on a page does not look inside an iframe, and document.querySelectorAll does not find elements inside a shadow DOM.
- DevTools gives a starting selector: Copy > Copy selector in Chrome, Copy > CSS Selector in Firefox. Simplify it before using it.
- In CurlShot, a selector that matches nothing returns status 422 with the code selector_not_found, and the request is not counted against the quota.

## The specimen page

The examples on this page run against one small page: a header with navigation, a hero, a pricing section with three cards, a chart, an orders table and a footer. This is all of its HTML.

```html
<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>
```

## Ten selectors and what each one captures

A capture of an element takes the first match of the selector, so the count matters as much as the match.

| Selector | Matches on the specimen page | Count | A capture returns | Robustness | Why |
| --- | --- | --- | --- | --- | --- |
| `#pricing` | The pricing section | 1 | The whole pricing section | Sturdy | An id names one element, and it often doubles as a link target, so it is rarely renamed. |
| `.card` | All three price cards | 3 | The Free card, because it comes first | Fair | A class written by a person survives layout changes, but it matches every card. |
| `.card:nth-child(2)` | The second card in the row | 1 | The Pro card | Fair | It names a position, not a thing. Add a card in front and it points at a different plan. |
| `[data-plan="pro"]` | The card whose data-plan attribute is pro | 1 | The Pro card | Sturdy | It names the thing itself, wherever it sits in the row. |
| `[data-testid="chart"]` | The figure that holds the chart | 1 | The chart with its caption | Sturdy | A test id exists for tools, so nobody changes it for visual reasons. |
| `main > section:first-of-type` | The first section directly inside main | 1 | The hero | Fair | It depends on the hero staying first. A banner section added above it takes its place. |
| `table tr:has(td.total)` | The table row that contains the total cell | 1 | The Total row | Sturdy | It picks the row by what is inside it, so rows can be added above without breaking it. |
| `.card:not(.featured)` | The cards without the featured class | 2 | The Free card, the first of the two | Fair | A fine way to exclude, but it still matches more than one element. |
| `.css-1q2w3e` | The chart drawing, by a generated class name | 1 | The bars without the caption | Brittle | A build tool made this name from the styles. The next deploy can produce a different one. |
| `body > main > section:nth-child(3) > figure` | The figure, by its full path from the top | 1 | The chart with its caption | Brittle | Four levels and a position. One new wrapper or one new section anywhere on the path breaks it. |

## CSS selector cheat sheet

The selectors that matter for picking one element, by kind. Every example works on the specimen page above. Support is from MDN.

### Id, class and type

Support: Every browser. [1]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `#id` | Id selector | The element whose id attribute has this value. | `#pricing` | the pricing section |
| `.class` | Class selector | Every element that has this class. | `.card` | three cards |
| `tag` | Type selector | Every element with this tag name. | `table` | the orders table |
| `tag.class` | Compound selector | Conditions written without a space must all hold for the same element. | `article.featured` | the Pro card |

### Attribute

Support: Across browsers since July 2015. [2]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `[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 child

Support: Every browser. [1]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `A B` | Descendant combinator | B anywhere inside A, at any depth. | `nav a` | three links |
| `A > B` | Child combinator | B that is a direct child of A. | `main > section` | three sections |

### Sibling

Support: Every browser. [1]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `A + B` | Next-sibling combinator | B that comes immediately after A, with the same parent. | `figure + table` | the orders table |
| `A ~ B` | Subsequent-sibling combinator | Every B that comes after A, with the same parent. | `.hero ~ section` | pricing and stats |

### Position

Support: Across browsers since July 2015; the "of selector" form is newer. [3]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `: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-type` | the hero |
| `:last-child` | Last child | The element that is the last child of its parent. | `tbody tr:last-child` | the Total row |

### Has

Support: Across browsers since December 2023. [4]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `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 |

### Not

Support: Across browsers since July 2015. [6]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `A:not(B)` | Negation pseudo-class | A, unless it also matches B. | `.card:not(.featured)` | the Free and Team cards |

### Is

Support: Across browsers since January 2021. [5]

| Pattern | Name | Meaning | Example | Selects |
| --- | --- | --- | --- | --- |
| `: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 |

## Which selectors are robust, from brittle to sturdy

A robust selector keeps pointing at the same thing after the site changes. This ranking is reasoning, not a measurement: it orders selectors by how much reason a site has to keep them.

1. **Generated class names** (`.css-1q2w3e`): CSS-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. **Deep positional chains** (`body > main > section:nth-child(3) > figure`): Every level and every number is a promise about the layout, and one new wrapper or sibling breaks the chain. [11]
3. **Meaningful class names** (`.pricing-table`): A 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. **Ids** (`#pricing`): An 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.
5. **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]

Playwright's documentation makes the same point for tests: "CSS and XPath are not recommended as the DOM can often change leading to non resilient tests", and "Testing by test ids is the most resilient way of testing". [11]

## How to find a CSS selector in 20 seconds

1. **Inspect the part you want.** Right-click it on the page. The developer tools open with that element selected in the page tree. Chrome: **Inspect**. Firefox: **Inspect Element**. [14, 18]
2. **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.
3. **Copy a selector.** Right-click the element in the tree and copy its selector. In Firefox, choose CSS Selector and not CSS Path: the path is the full chain from the top of the page. Chrome: **Copy > Copy selector**. Firefox: **Copy > CSS Selector**. [15, 17]
4. **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. `#pricing` beats `#app > div:nth-child(2) > section.css-x1y2z3`.
5. **Count the matches in the console.** Open the Console and run `document.querySelectorAll("#pricing").length`. You want 1. A result of 0 means a typo or a late element, and an invalid selector throws a SyntaxError. In Chrome, `$$("#pricing")` is a shorter way to get the matches. [8, 16]
6. **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]

## What goes wrong with element screenshots

### The selector matches nothing

- Cause: The element is added by a script after the page has loaded.
- Cause: 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.
- Cause: The element is inside a shadow DOM, the private tree of a web component. `document.querySelectorAll` does not find elements in there.
- Fix: Wait for the element before capturing. For an iframe, capture the `iframe` element 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 canvas` or `.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.

## Selector recipes for six common targets

The selectors are typical patterns to adapt: the names on your page will differ.

| Target | Typical selectors | What helps | Options that help |
| --- | --- | --- | --- |
| 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 CurlShot

In CurlShot the option is `selector`. It takes one CSS selector of up to 1000 characters and returns an image of the first element that matches. This request captures the heading of a page, waits for it first, hides two overlays and doubles the pixel density:

```bash
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.png
```

In a web address `#` must be written `%23`, or everything after it is dropped before the request is sent.

| Situation | What happens | Error |
| --- | --- | --- |
| One element matches | An image exactly as big as the element, in page pixels times `device_scale_factor`. | none |
| Several elements match | The first one in document order is captured. The others are ignored. | none |
| 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. | none |
| The element is taller or wider than the window | It is captured whole, from the document and not from the window. | none |
| 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. | none |
| 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

| Option | What it does |
| --- | --- |
| `wait_for_selector` | Waits until an element matching this selector is visible, for as long as `timeout` allows (30 seconds by default). |
| `hide_selectors` | Hides every element that matches any selector in the list (up to 50) with `display: none` before 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. |

The renderer works in this order: `wait_for_selector`, then `delay`, then `styles`, then `hide_selectors`, then `scripts`, then `click`, then `selector`. There is no padding option and no scroll-into-view option: padding comes from `styles`, and scrolling to the element is automatic.

Read more: [element screenshot tool](https://curlshot.com/tools/element-screenshot), [capturing an element](https://curlshot.com/docs/element), [waiting and timing](https://curlshot.com/docs/waiting).

## 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

### 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": https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors/Selectors_and_combinators (checked 11 October 2026)
2. MDN Web Docs, "Attribute selectors": https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors (checked 11 October 2026)
3. MDN Web Docs, ":nth-child()": https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child (checked 11 October 2026)
4. MDN Web Docs, ":has()": https://developer.mozilla.org/en-US/docs/Web/CSS/:has (checked 11 October 2026)
5. MDN Web Docs, ":is()": https://developer.mozilla.org/en-US/docs/Web/CSS/:is (checked 11 October 2026)
6. MDN Web Docs, ":not()": https://developer.mozilla.org/en-US/docs/Web/CSS/:not (checked 11 October 2026)
7. MDN Web Docs, "Document: querySelector() method": https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector (checked 11 October 2026)
8. MDN Web Docs, "Document: querySelectorAll() method": https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll (checked 11 October 2026)
9. MDN Web Docs, "Using shadow DOM": https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM (checked 11 October 2026)
10. MDN Web Docs, "HTMLIFrameElement: contentDocument property": https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/contentDocument (checked 11 October 2026)
11. Playwright, "Locators": https://playwright.dev/docs/locators (checked 11 October 2026)
12. Playwright, "Other locators: CSS locator": https://playwright.dev/docs/other-locators (checked 11 October 2026)
13. Playwright, "Best practices": https://playwright.dev/docs/best-practices (checked 11 October 2026)
14. Chrome for Developers, "Get started with viewing and changing the DOM": https://developer.chrome.com/docs/devtools/dom (checked 11 October 2026)
15. Chrome for Developers, "What's new in DevTools (Chrome 72): Copy JS path": https://developer.chrome.com/blog/new-in-devtools-72 (checked 11 October 2026)
16. Chrome for Developers, "Console utilities API reference": https://developer.chrome.com/docs/devtools/console/utilities (checked 11 October 2026)
17. Firefox Source Docs, "Examine and edit HTML": https://firefox-source-docs.mozilla.org/devtools-user/page_inspector/how_to/examine_and_edit_html/index.html (checked 11 October 2026)
18. Firefox Source Docs, "Open the Inspector": https://firefox-source-docs.mozilla.org/devtools-user/page_inspector/how_to/open_the_inspector/index.html (checked 11 October 2026)

## Go deeper

- [Element screenshot tool](https://curlshot.com/tools/element-screenshot)
- [Docs: capturing an element](https://curlshot.com/docs/element)
- [Docs: waiting and timing](https://curlshot.com/docs/waiting)
- [Docs: custom CSS, JavaScript and clicks](https://curlshot.com/docs/customize)

## Read next

- [Why website screenshots look wrong, and the fixes](https://curlshot.com/blog/why-website-screenshots-look-wrong.md): A 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.
- [Visual regression testing, explained in pictures](https://curlshot.com/blog/visual-regression-testing-guide.md): How 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.
- [Website change monitoring with screenshots](https://curlshot.com/blog/website-change-monitoring-with-screenshots.md): How 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.
