Batch‑screenshot HTML files and live URLs to images with headless Chromium — including every in‑page tab, modal, and nested screen, behind logins, at retina quality.
htmlshot is a single, self‑contained macOS/Linux bash script. Point it at an HTML file, a folder of prototypes, or a URL and it renders crisp screenshots — not just the landing view, but every state a page can reach: navigation sections, tab bars, secondary side‑menus, modals, and even the tabs inside modals. For real websites it captures full, retina‑sharp, section‑complete pages and can crawl a whole site (optionally behind a form login).
It self‑installs Node.js + Puppeteer/Chromium (and pngjs) on first run into ~/.htmlshot. No npm install, no config files.
./htmlshot.sh ./prototypes ./shots -f # every screen of every prototype
./htmlshot.sh https://example.com ./shots -f # full, retina screenshot of a site- Why
- Features
- Requirements
- Install
- Quick start
- Capture modes
- Options
- Output naming
- Examples
- How it works
- Troubleshooting
- Performance
- Contributing
- License
Most screenshot tools capture what loads first. But a modern admin panel, design export, or app renders many screens on one page — switched by JavaScript, revealed by tabs, or opened as modals. And a single "full‑page" screenshot of a real website usually comes out blurry and half‑blank (lazy images and scroll‑reveal sections never load).
htmlshot solves both:
- For prototypes / design exports (e.g.
*.dc.html): it discovers and visits every tab, side‑menu, and modal — recursively — and saves each state as its own image. - For live URLs: it hydrates lazy content, captures the page in retina tiles, and stitches them into one sharp, complete image — even for very tall pages that exceed Chrome's screenshot size limit.
- 📁 Batch a single file, a whole folder (optionally recursive), or a URL.
- 🧭 Deep state crawl — auto‑discovers tab groups and modal‑opener buttons at every level and captures each (nested side‑menus, in‑screen tabs, modals, and in‑modal tabs). Depth‑limited and capped for safety.
- 🖱️ Works with JS‑driven prototypes that have no anchors (buttons wired with
onClick), not just#‑anchor SPAs. - 🌐 URL screenshots at desktop width + retina (2×) by default.
- 🧵 Tiled + stitched full‑page capture — sharp text, every section rendered, no Chrome height limit, and fixed elements (chat widgets, sticky navs) shown once instead of repeating.
- 🕸️ Same‑domain site crawl (
--follow) — screenshot every linked page, honoring the state modes per page. - 🔐 Form login — sign in once and capture the authenticated backend (credentials via env vars, kept out of shell history).
- 🌙 Dark‑mode emulation, custom viewport, device scale,
--selectorelement capture,png/jpeg/webp, quality, delays, timeouts. - 🧰 Zero setup — self‑bootstraps Node + Puppeteer/Chromium + pngjs on first run. Prefers your installed Google Chrome to avoid Apple‑Silicon/Rosetta issues.
- 🎛️ Friendly CLI: progress bar, dry‑run,
--overwrite,--quiet,--no-color.
- macOS or Linux with bash 3.2+ (works with the stock macOS bash).
- Node.js — auto‑installed via Homebrew on macOS if missing; otherwise install from nodejs.org.
- Internet access on first run (to download Chromium) and for URL captures.
- Google Chrome is used if present (recommended); otherwise Puppeteer's bundled Chromium.
git clone https://github.com/<you>/htmlshot.git
cd htmlshot
chmod +x htmlshot.sh
./htmlshot.sh --helpThe first real run downloads Node (if needed) + Puppeteer/Chromium + pngjs into ~/.htmlshot (~150–300 MB, one time). Reinstall anytime with --clean.
Optionally symlink it onto your PATH:
ln -s "$PWD/htmlshot.sh" /usr/local/bin/htmlshot# One HTML file → ./shots/index.png
./htmlshot.sh ./index.html ./shots
# A folder of prototypes, full‑page — captures EVERY tab/modal of every file
./htmlshot.sh ./prototypes ./shots -f
# A single live website, full‑page, retina, section‑complete
./htmlshot.sh https://example.com ./shots -f
# Preview what would happen, write nothing
./htmlshot.sh ./src ./out --dry-runFor local .html/.htm sources, three layered modes are on by default (most powerful wins):
| Mode | What it captures |
|---|---|
--crawl (default) |
Recursively opens tab groups and modal‑opener buttons at every level: nested side‑menus, in‑screen tabs, modals, and in‑modal tabs. |
--auto-tabs |
One level of auto‑detected nav/tab groups (a container with ≥ --tab-min sibling buttons/links). |
--states |
In‑page states via # anchors, or an explicit --state-selector. |
Turn any off with =no (or --no-…):
./htmlshot.sh ./admin.html ./out --crawl=no # fall back to shallow tabs
./htmlshot.sh ./page.html ./out --crawl=no --auto-tabs=no --states=no # base onlyHow the crawl stays safe & sane:
- It clicks tab groups (≥
--tab-minsibling buttons/links) and opener buttons whose text looks like it opens something (New,Add,Create,Edit,View,Open,Details,Manage, …). - It never clicks
Delete,Remove,Save,Submit,Cancel,Close, or logout controls. - Each state is reached by replaying a fresh click‑path from a reload, so states never stack and nothing persists.
confirm()/alert()dialogs are auto‑dismissed so a stray one can't hang a click.- Depth is bounded by
--crawl-depth(default 3) and total shots per file by--max-shots(default 120).
When the source is an http(s) URL, htmlshot uses better defaults for real websites:
- 1440px wide @2× retina (vs 1280 @1× for local files) → crisp text.
--wait-until load(live sites rarely reachnetworkidlebecause of analytics/ads/websockets).- Resilient navigation — a navigation timeout is treated as non‑fatal (the DOM is usually painted); genuine errors (DNS, refused) still fail.
- Full hydration before capture — promotes lazy images to eager, resolves
data-src/data-bg, scrolls to trigger reveals, makes animations instant, and waits for images to decode. - Tiled + stitched full‑page (
--stitch, auto‑on for-fURLs) — scrolls a viewport at a time, screenshots each strip (so scroll‑driven/pinned sections paint), and assembles them withpngjs. This beats Chrome's ~32k‑px screenshot height limit and keepsposition: fixedwidgets (chat, sticky nav) from repeating down the image.
Safety: for live URLs, state‑clicking is off unless you explicitly pass
--crawl,--auto-tabs,--states, or--follow. A URL just gets a clean full capture by default — clicking random buttons on a real site could submit forms or mutate data.
Disable stitching with --no-stitch (falls back to Chrome's native full‑page). Override the retina defaults anytime with -w/-s.
Screenshot a seed URL, then walk its links breadth‑first — same‑domain by default:
./htmlshot.sh https://example.com ./out --follow --follow-depth 2 --max-pages 40 -f--follow-depth N— link hops from the seed (default 2).--max-pages N— cap on pages (default 50).--same-domain=no(alias--allow-external) — allow off‑domain links.- Each followed page honors the state modes: add
--crawlto deep‑capture every page's tabs/modals too.
Sign in via a form once; the session cookie is reused for every subsequent capture (single URL, follow, or crawl):
export HTMLSHOT_PASSWORD='s3cret' # keep the password out of shell history
./htmlshot.sh https://app.example.com/dashboard ./out \
--login-url https://app.example.com/login --username admin@example.com \
--follow --crawl -f- Credentials come from
--username/--passwordorHTMLSHOT_USERNAME/HTMLSHOT_PASSWORD(env is preferred). - Field selectors auto‑detect (
input[type=password], and email/user/text inputs for the username), or pin them with--user-selector/--pass-selector/--submit-selector. Submit falls back to pressing Enter. --login-urldefaults to the source URL.--login-wait(default 2500 ms) controls the post‑submit wait.
Login is best‑effort: it submits and waits, it doesn't verify success. Multi‑step logins, CSRF flows, or CAPTCHAs may need tuning.
USAGE
htmlshot <source> <target> [options]
htmlshot -o <target> <source> [options]
<source> A single .html/.htm file, a folder of them, or an http(s) URL.
<target> Output folder for the images (created if missing).
Rendering
| Option | Description | Default |
|---|---|---|
-w, --width PX |
Viewport width | 1280 (URLs: 1440) |
-H, --height PX |
Viewport height | 800 |
-s, --scale N |
Device scale / retina | 1 (URLs: 2) |
-f, --full-page |
Capture full scroll height | off |
-F, --format FMT |
png | jpeg | webp |
png |
-q, --quality N |
Quality 0–100 (jpeg/webp) | — |
-r, --recursive |
Recurse into subfolders | off |
-d, --delay MS |
Wait after load before capture | 0 |
--wait-until EVENT |
load | domcontentloaded | networkidle0 | networkidle2 |
networkidle2 (URLs: load) |
--selector CSS |
Capture only one element | — |
--timeout MS |
Navigation timeout | 30000 |
--dark |
Emulate prefers-color-scheme: dark |
off |
State capture (all on by default for local files; disable with =no)
| Option | Description | Default |
|---|---|---|
--crawl[=no] |
Deep crawl: nested menus, sub‑tabs, modals, in‑modal tabs | on (local) |
--auto-tabs[=no] |
Auto‑detect one level of nav/tab groups | on (local) |
--states[=no] |
In‑page states via anchors / --state-selector |
on (local) |
--state-selector CSS |
Trigger elements to click | a[href^="#"] |
--tab-min N |
Min sibling buttons to count as a tab group | 3 |
--crawl-depth N |
Levels deep to follow | 3 |
--max-shots N |
Per‑file cap on captured states | 120 |
--no-base |
Skip the initial (unclicked) shot | off |
URL sources
| Option | Description | Default |
|---|---|---|
--stitch[=no] |
Tiled retina full‑page capture | auto‑on for -f URLs |
--follow[=no] |
Follow links & shot each page | off |
--follow-depth N |
Link hops from the seed | 2 |
--max-pages N |
Max pages to screenshot | 50 |
--same-domain=no |
Allow off‑domain links (alias --allow-external) |
same‑domain |
Login
| Option | Description | Default |
|---|---|---|
--login |
Enable form login (with env creds) | off |
--login-url URL |
Page holding the form | source URL |
--username V |
Username/email (or HTMLSHOT_USERNAME) |
— |
--password V |
Password (or HTMLSHOT_PASSWORD, preferred) |
— |
--user-selector CSS |
Username field | auto |
--pass-selector CSS |
Password field | input[type=password] |
--submit-selector CSS |
Submit control | auto / Enter |
--login-wait MS |
Wait after submit | 2500 |
Misc
| Option | Description |
|---|---|
-o, --output DIR |
Target folder (alt to positional arg) |
--overwrite |
Overwrite existing images (else skip) |
-n, --dry-run |
List what would be captured, do nothing |
--clean |
Reinstall dependencies |
--quiet |
Suppress decorative output |
--no-color |
Disable ANSI colors |
-h, --help / -v, --version |
Help / version |
- File / folder:
basename.extfor the base view; each captured state adds a readable, filename‑safe suffix:admin.png base view admin__dashboard.png a top‑level tab admin__settings__storage.png a nested side‑menu screen admin__plans__new-plan__week.png a tab inside a modal - URL: host + path slug, e.g.
www-example-com.png,www-example-com-pricing.png. Followed pages are named from their URLs and de‑duplicated.
Existing images are skipped unless --overwrite is passed. Recursive folder mode is flat output (state names are suffixed; same‑basename files may overwrite).
# One file → ./shots/index.png
./htmlshot.sh ./index.html ./shots
# A whole prototype folder, full‑page, retina
./htmlshot.sh ./felt-prototype ./shots -f -s 2
# Recursive, JPEG at 85% quality, dark mode
./htmlshot.sh ./src -o ./out -r -F jpeg -q 85 --dark
# Mobile‑sized capture of a single component
./htmlshot.sh ./ui.html ./out -w 390 -H 844 --selector ".phone"
# Design export (JS‑driven, no anchors): auto‑shot every tab/screen
./htmlshot.sh "./Admin Panel.dc.html" ./out --auto-tabs -f -d 400
# Deep: nested side‑menus, in‑screen tabs, modals AND in‑modal tabs (default)
./htmlshot.sh "./Admin Panel.dc.html" ./out -f
# Just the base view, no clicking around
./htmlshot.sh ./page.html ./out --crawl=no --auto-tabs=no --states=no
# Single live URL (retina, stitched, section‑complete)
./htmlshot.sh https://example.com ./out -f
# Crawl a site, same‑domain only, 2 hops, up to 40 pages
./htmlshot.sh https://example.com ./out --follow --follow-depth 2 --max-pages 40 -f
# Log in, then deep‑crawl the whole authenticated backend
export HTMLSHOT_PASSWORD='s3cret'
./htmlshot.sh https://app.example.com/dashboard ./out \
--login-url https://app.example.com/login --username admin@ex.com \
--follow --crawl -fhtmlshot is two layers in one file:
- Bash wrapper — argument parsing, dependency bootstrapping (Node via Homebrew, Puppeteer + pngjs), source discovery (file / folder / URL), job‑file creation, and the progress UI. Compatible with macOS bash 3.2+.
- Embedded Node/Puppeteer runner — written at runtime to
~/.htmlshot/runner.js. It launches one Chromium instance, then for each job:- Local/URL page: loads it, hydrates lazy content, and runs
captureStates()— base shot plus the deep crawl or shallow states. - Deep crawl: an injected in‑page helper (
window.__HS) discovers tab groups and opener buttons; the runner walks them by replaying fresh click‑paths, capturing each state. - Full‑page URL:
stitchCapture()scrolls in viewport strips, screenshots each, and assembles them withpngjs. - Follow mode: breadth‑first same‑domain link walk, capturing each page.
- Login: a one‑time form submit so the session cookie is shared.
- Local/URL page: loads it, hydrates lazy content, and runs
Configuration passes via HS_* environment variables. The runner emits tab‑separated protocol lines the wrapper parses for progress: @@TOTAL, @@OK, @@FAIL, @@WARN, @@NOTE, @@FATAL, @@DONE.
Runtime layout
~/.htmlshot/
├── node_modules/ puppeteer, pngjs, …
├── runner.js generated on each run
└── install.log first‑run install output
| Symptom | Fix |
|---|---|
| URL fails with "Navigation timeout" | Already handled — timeouts are non‑fatal and URLs default to --wait-until load. For a stubborn page: --wait-until domcontentloaded --timeout 60000. |
| Blank / faint sections on a real site | Give scroll‑reveal/animation sections more time: add -d 1500. Ensure -f (stitching) is on. |
| A sidebar / panel / chat repeats down the page | Handled automatically: in stitched captures, position: sticky columns are un‑stuck (rendered once in flow) and position: fixed overlays are shown on the first strip only. |
| Low‑resolution text | URLs already default to @2×. Bump further with -s 3, or widen with -w 1920. |
| A modal/tab wasn't captured | Its opener text may not match the recognized verbs — pin it with --state-selector "CSS", or lower --tab-min. |
| Too many near‑duplicate shots | Raise --tab-min 4 or lower --crawl-depth 2. |
| Hit the shot cap | Raise --max-shots. |
| Very tall page uses lots of memory | The scale factor auto‑drops past a pixel ceiling; you can also pass -s 1. |
| Login didn't work | Pin --user-selector / --pass-selector / --submit-selector, increase --login-wait, and confirm the flow isn't multi‑step / CAPTCHA‑gated. |
| Install problems | Re‑run with --clean; check ~/.htmlshot/install.log. |
- Local prototypes use Chrome's fast native full‑page path — a folder of files captures in seconds.
- URL stitching is deliberate: it hydrates, scrolls tile‑by‑tile, and assembles a high‑res image. A long page can take ~60–120 s. That's the cost of a complete, retina‑sharp capture. Use
--no-stitchfor a quick (lower‑fidelity) native shot. - Concurrency is one page at a time per run for deterministic, low‑memory behavior.
Issues and PRs welcome. Because the tool is a single self‑contained script:
- Keep it bash 3.2‑compatible (no
mapfile, no associative arrays, avoid Bash‑4‑only constructs). - The Node runner is embedded as a quoted heredoc in
write_runner()— edit it there. - There's no build step. Validate with:
bash -n htmlshot.sh # shell syntax ./htmlshot.sh <file> <out> --dry-run node -c ~/.htmlshot/runner.js # runner JS syntax (after a run)
htmlshot is built and maintained by IndiaNIC Infotech Limited. Founded in 1998, IndiaNIC is a global software and product engineering company delivering web, mobile, UI/UX, and AI solutions to businesses worldwide. Tools like this one come out of our day‑to‑day design and QA workflow — screenshotting client prototypes, admin panels, and live sites at scale — and we open‑source them so the wider community can benefit. Learn more at www.indianic.com.
MIT © IndiaNIC Infotech Limited. See LICENSE.