Take screenshots of webpages or HTML files in .NET (C#)
Use Selenium WebDriver to open a page in headless Chrome and save its visible viewport as a PNG. The C# console application below accepts a local HTML path or an HTTP URL, waits for an element that signals readiness, and saves the screenshot without replacing an existing file.
Prerequisites
This walkthrough uses Bash on Linux, the .NET 10 SDK,
and an installed Chrome or Chromium browser with a matching ChromeDriver. It was tested with SDK
10.0.111 and Chromium/ChromeDriver 152.0.7977.82. Install current servicing updates for .NET 10,
which is an actively supported LTS release.
The optional local HTTP example also needs a maintained Python 3 installation with the
http.server --bind and --directory options; it was tested with Python 3.14.7.
Only render pages and HTML files you trust. A browser can execute their JavaScript and make network requests. This example captures the viewport, including any blank areas; it does not capture a whole scrolling page, validate HTTP status codes, or establish that an arbitrary application has finished loading.
Check the installed tools before creating the project:
dotnet --version && chromium --version && chromedriver --version
For the HTTP example, check Python and confirm that its help lists both options before creating the project:
python3 --version && python3 -m http.server --help
If you use Google Chrome, substitute its executable for chromium. Set these paths to the actual
browser and matching driver on your machine. These are the paths used for this Linux example:
export CHROME_BINARY=/usr/bin/chromium &&
export CHROMEDRIVER_PATH=/usr/bin/chromedriver
Using Selenium WebDriver
Start in a writable directory outside an existing .NET repository. Enclosing SDK, NuGet, and MSBuild
configuration can change a new project's behavior. The setup block refuses those ancestor files
and an existing WebpageScreenshots destination before creating anything. Run it from Bash:
(
cd -P . || exit 1
parent="$PWD"
while :; do
for config in global.json NuGet.Config NuGet.config nuget.config \
Directory.Build.props Directory.Build.targets Directory.Packages.props; do
if [ -e "$parent/$config" ]; then
printf 'Choose a directory outside this .NET configuration: %s\n' "$parent/$config" >&2
exit 1
fi
done
[ "$parent" = / ] && break
parent=${parent%/*}
[ -n "$parent" ] || parent=/
done
if [ -e WebpageScreenshots ]; then
printf 'WebpageScreenshots already exists; keep it and choose a new directory.\n' >&2
exit 1
fi
dotnet new console -n WebpageScreenshots --framework net10.0 --no-restore &&
dotnet add WebpageScreenshots package Selenium.WebDriver --version 4.28.0 &&
dotnet add WebpageScreenshots package Selenium.Support --version 4.28.0
)
The packages are pinned to the tested Selenium 4.28.0 release. If package installation fails, keep the new project, fix the reported NuGet/network problem, and retry the dependency steps from the same parent directory:
dotnet add WebpageScreenshots package Selenium.WebDriver --version 4.28.0 &&
dotnet add WebpageScreenshots package Selenium.Support --version 4.28.0
Replace the generated WebpageScreenshots/Program.cs with this complete program. The arguments are
input output.png width height ready-element-id timeout-seconds. Width and height describe the
viewport in CSS pixels; the timeout applies separately to navigation and the readiness wait.
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI;
try
{
if (args.Length == 3 && args[0] == "cleanup")
{
int days = int.Parse(args[2], CultureInfo.InvariantCulture);
if (days < 1) throw new ArgumentException("Retention must be at least one day.");
string directory = Path.GetFullPath(args[1]);
DateTime cutoff = DateTime.UtcNow.AddDays(-days);
foreach (string file in Directory.EnumerateFiles(directory, "webshot-*.png"))
{
if (Regex.IsMatch(Path.GetFileName(file), @"\Awebshot-[A-Za-z0-9_-]+\.png\z")
&& File.GetLastWriteTimeUtc(file) < cutoff)
{
File.Delete(file);
Console.WriteLine($"Deleted {file}");
}
}
return 0;
}
if (args.Length != 6)
throw new ArgumentException("Usage: input output.png width height ready-element-id timeout-seconds; or cleanup directory days");
int width = int.Parse(args[2], CultureInfo.InvariantCulture);
int height = int.Parse(args[3], CultureInfo.InvariantCulture);
int seconds = int.Parse(args[5], CultureInfo.InvariantCulture);
if (width < 200 || width > 3840 || height < 200 || height > 2160)
throw new ArgumentException("Choose a viewport from 200x200 to 3840x2160.");
if (seconds < 1 || seconds > 60 || string.IsNullOrWhiteSpace(args[4]))
throw new ArgumentException("Supply a readiness element ID and a timeout from 1 to 60 seconds.");
string url;
if (Uri.TryCreate(args[0], UriKind.Absolute, out Uri? remote)
&& (remote.Scheme == "http" || remote.Scheme == "https"))
{
url = remote.AbsoluteUri;
}
else
{
string path = Path.GetFullPath(args[0]);
if (!File.Exists(path)) throw new FileNotFoundException("HTML file not found.", path);
// Escape each Linux path segment so literal %, #, and ? keep their filename identity.
url = "file://" + string.Join("/", path.Split('/').Select(Uri.EscapeDataString));
}
string outputPath = Path.GetFullPath(args[1]);
if (!outputPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("The output filename must end in .png.");
if (File.Exists(outputPath) || Directory.Exists(outputPath))
throw new IOException("Output already exists; choose a new filename.");
string browser = Environment.GetEnvironmentVariable("CHROME_BINARY")
?? throw new ArgumentException("Set CHROME_BINARY to your browser executable.");
string driverPath = Environment.GetEnvironmentVariable("CHROMEDRIVER_PATH")
?? throw new ArgumentException("Set CHROMEDRIVER_PATH to your driver executable.");
if (!File.Exists(browser) || !File.Exists(driverPath))
throw new FileNotFoundException("Browser or ChromeDriver executable not found.");
var options = new ChromeOptions { BinaryLocation = browser };
options.AddArgument("--headless=new");
using var service = ChromeDriverService.CreateDefaultService(
Path.GetDirectoryName(driverPath), Path.GetFileName(driverPath));
byte[] png;
object result;
using (var driver = new ChromeDriver(service, options, TimeSpan.FromSeconds(30)))
{
driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(seconds);
driver.Manage().Window.Size = new System.Drawing.Size(Math.Max(800, width), Math.Max(600, height));
driver.ExecuteCdpCommand("Emulation.setDeviceMetricsOverride", new Dictionary<string, object>
{
["width"] = width,
["height"] = height,
["deviceScaleFactor"] = 1,
["mobile"] = false
});
driver.Navigate().GoToUrl(url);
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(seconds));
wait.Until(d => d.FindElement(By.Id(args[4])).Displayed);
png = driver.TakeScreenshot().AsByteArray;
var window = driver.Manage().Window.Size;
result = new
{
url = driver.Url,
viewportWidth = driver.ExecuteScript("return window.innerWidth"),
viewportHeight = driver.ExecuteScript("return window.innerHeight"),
windowWidth = window.Width,
windowHeight = window.Height,
devicePixelRatio = driver.ExecuteScript("return window.devicePixelRatio")
};
}
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
using (var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write))
output.Write(png);
Console.WriteLine($"Saved {args[1]}");
Console.WriteLine(JsonSerializer.Serialize(result));
return 0;
}
catch (Exception error)
{
Console.Error.WriteLine($"Screenshot failed: {error.Message}");
return 1;
}
The using scopes dispose the browser and driver service on normal completion and exceptions.
The program buffers the PNG before opening the destination, so a navigation or readiness failure
creates no screenshot. FileMode.CreateNew also refuses an output created since the initial check.
A disk-write failure can leave a partial new file; inspect and remove only that file before retrying.
Browser compatibility
This implementation selects Chrome and ChromeDriver explicitly. Keep their versions compatible; ChromeDriver's version selection guide explains how to obtain a matching pair. Selenium Manager can discover and download drivers, but this program uses the paths you supplied instead. Firefox and Edge need their respective driver and options classes; they are outside this walkthrough.
Capturing local HTML files
Save this page as WebpageScreenshots/sample.html. It has a blue background and a white card with
the text LOCAL PAGE. The readiness marker is present immediately:
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local screenshot example</title>
<style>
html, body { margin: 0; background: #0c5393; }
#content-loaded { margin: 32px; padding: 24px; background: white; color: black;
font: bold 32px sans-serif; }
</style>
<h1 id="content-loaded">LOCAL PAGE</h1>
</html>
Build and run from the parent directory. && prevents a failed build from running an older binary,
and -- passes the remaining arguments to the application:
dotnet build WebpageScreenshots --disable-build-servers &&
dotnet run --project WebpageScreenshots --no-build -- \
WebpageScreenshots/sample.html screenshots/webshot-local.png 800 600 content-loaded 10
On success, the terminal prints Saved screenshots/webshot-local.png followed by JSON describing the
actual URL, viewport, outer window, and pixel ratio. Open that PNG: it should be 800 × 600 pixels,
with the complete LOCAL PAGE card near the top and a blue background
extending to the bottom.
The JSON viewport should be 800 × 600 with a pixel ratio of one. A nonempty PNG alone does not prove
that you captured the intended page.
Handling dynamic content
Navigation waits for the document, but JavaScript can update the page afterward. Use a visible readiness element that your application reveals only when the desired content is ready. A visible wrapper that exists while its data is still loading is not a suitable signal.
Save this second page as WebpageScreenshots/delayed.html. The card starts hidden and appears after
1.5 seconds, with a green background that makes an early screenshot easy to spot:
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Delayed screenshot example</title>
<style>
html, body { margin: 0; background: white; }
#content-loaded { margin: 32px; padding: 24px; background: white; color: black;
font: bold 32px sans-serif; }
</style>
<h1 id="content-loaded" hidden>DELAYED PAGE</h1>
<script>
setTimeout(() => {
document.documentElement.style.background = '#198754';
document.body.style.background = '#198754';
document.getElementById('content-loaded').hidden = false;
}, 1500);
</script>
</html>
For a reproducible HTTP source, check python3 --version, then run this foreground server in a
second terminal from the same parent directory. It serves the selected directory on loopback:
python3 -m http.server 8765 --bind 127.0.0.1 --directory WebpageScreenshots
If that port is occupied, choose an unused port and update both URLs below. Keep the server running while capturing the static and delayed pages:
dotnet run --project WebpageScreenshots --no-build -- \
http://127.0.0.1:8765/sample.html screenshots/webshot-http.png 800 600 content-loaded 10 &&
dotnet run --project WebpageScreenshots --no-build -- \
http://127.0.0.1:8765/delayed.html screenshots/webshot-delayed.png 800 600 content-loaded 10
Open both files. The static image should match the blue local page; the delayed image should show
DELAYED PAGE on its white card against green, at 800 × 600 pixels.
An all-white delayed image means you captured too early. Stop the server with Ctrl+C in its
terminal when finished.
An absent or permanently hidden marker makes the CLI exit with status one and no new PNG after
the wait expires. For a real application, replace content-loaded with its actual readiness ID.
Managing screenshot storage
Capture into a directory you own and choose a new filename for every retained image. Repeating a command with its existing destination fails and preserves that file. Neighboring PNGs are not touched by capture.
For optional retention, reserve filenames matching webshot-[letters, digits, underscores, or hyphens].png
for this application. From the parent directory, this command deletes matching files whose last
write time is more than seven days old, without entering subdirectories:
dotnet run --project WebpageScreenshots --no-build -- cleanup screenshots 7
Other filenames are left alone. The prefix is a convention, so use cleanup only in a directory you control where every matching file belongs to this application. To retain all screenshots, omit cleanup. A deletion error returns status one; earlier deletions in that run are not rolled back.
Handling different screen sizes
The CLI uses Chrome's device metrics override to set the viewport and a pixel ratio of one. The outer browser window can be larger, especially at narrow widths. This tests CSS breakpoints without changing the user agent or enabling mobile touch emulation; it does not reproduce a particular phone.
For a narrow viewport, keep the same input and readiness marker and change the dimensions:
dotnet run --project WebpageScreenshots --no-build -- \
WebpageScreenshots/sample.html screenshots/webshot-narrow.png 375 667 content-loaded 10
Check that the PNG is 375 × 667 pixels and the card remains readable. The program accepts widths from 200 to 3,840 and heights from 200 to 2,160; larger captures are outside this example's limits.
Diagnose a failed capture
Use the CLI's nonzero status and error message to find the failing step. A missing HTML file is reported before Chrome starts. A missing or incompatible browser/driver prevents a session from starting. For a readiness timeout, check the ID and whether the element becomes visible; increasing the timeout will not fix a wrong marker. For an output error, check directory permissions and pick an unused filename. Preserve previous screenshots while fixing the input or setup, then rerun the same command with a new destination.
