How to Convert HTML to an Image in C#

By Jeffrey Needles

October 13, 2026
Tutorial
Html to Image
Csharp

How to Convert HTML to an Image in C#

To convert HTML to an image in C#, load it into headless Chromium with PuppeteerSharp and take a screenshot. Chromium handles the CSS, fonts and layout, so you never have to measure text or position elements yourself.

HCTI is mostly a .NET codebase, and we use PuppeteerSharp in our own code, so this is the version of the tutorial we have the strongest opinions about. Getting the first screenshot working is easy. The parts that cause trouble later are where the browser comes from, what happens when twenty requests want it at once, and why the server's output doesn't match your machine. We'll cover those too.

The example is a delivery update card showing a tracking number, destination and expected delivery date. It's built from a C# template and saved as a PNG. At the end, we'll render the same card through the typed HTML/CSS to Image client.

1. Set Up a .NET Console Project

The examples use a .NET 10 console app and C# raw string literals:

dotnet new console -n ShipmentImage -f net10.0
cd ShipmentImage
dotnet add package PuppeteerSharp

PuppeteerSharp is a .NET port of Puppeteer. One thing that surprises people: the NuGet package doesn't include a browser. The code below uses BrowserFetcher to download Chromium on first run and then passes the executable path to the launcher.

2. Create the Shipment Card HTML

Add a file named ShipmentTemplate.cs. It takes plain application data and returns a complete HTML document:

using System.Net;

public static class ShipmentTemplate
{
    public static string Create(
        string customer, string tracking, string destination, string delivery)
    {
        // These values are text, not HTML supplied by the customer.
        static string Encode(string value) => WebUtility.HtmlEncode(value);

        return $$"""
        <!doctype html>
        <html lang="en">
        <head>
          <meta charset="utf-8">
          <title>Shipment update</title>
          <style>
            * { box-sizing: border-box; }
            body { margin: 0; background: white; font-family: Arial, sans-serif; color: #172b3a; }
            #shipment-image { width: 600px; padding: 24px; background: #eaf1f5; }
            .card { padding: 32px; border-radius: 18px; background: white; }
            .brand { font-size: 13px; font-weight: bold; letter-spacing: 2px; }
            .status { display: inline-block; margin-top: 24px; padding: 7px 12px;
                      border-radius: 20px; background: #ddf4e7; color: #17613d;
                      font-size: 13px; font-weight: bold; }
            h1 { margin: 16px 0 8px; font-size: 30px; line-height: 1.2; }
            .intro { margin: 0; line-height: 1.5; color: #526575; }
            dl { margin: 28px 0 0; padding-top: 20px; border-top: 1px solid #e2e9ee; }
            .detail { display: flex; justify-content: space-between; gap: 24px;
                      padding: 8px 0; font-size: 14px; line-height: 1.4; }
            dt { color: #526575; }
            dd { margin: 0; font-weight: bold; text-align: right; overflow-wrap: anywhere; }
            .progress { display: flex; gap: 6px; margin-top: 24px; }
            .progress span { flex: 1; height: 6px; border-radius: 6px; background: #dce5eb; }
            .progress .done { background: #259764; }
          </style>
        </head>
        <body>
          <main id="shipment-image">
            <article class="card">
              <div class="brand">NORTHLINE GOODS</div>
              <div class="status">Out for delivery</div>
              <h1>Your package is on its way</h1>
              <p class="intro">{{Encode(customer)}}, here's the latest on your order.</p>
              <dl>
                <div class="detail"><dt>Tracking</dt><dd>{{Encode(tracking)}}</dd></div>
                <div class="detail"><dt>Destination</dt><dd>{{Encode(destination)}}</dd></div>
                <div class="detail"><dt>Expected delivery</dt><dd>{{Encode(delivery)}}</dd></div>
              </dl>
              <div class="progress" aria-label="Delivery stage 3 of 4">
                <span class="done"></span><span class="done"></span>
                <span class="done"></span><span></span>
              </div>
            </article>
          </main>
        </body>
        </html>
        """;
    }
}

The $$""" prefix is what makes this pleasant to write. Single braces in the CSS stay as they are, and interpolation uses double braces, like {{Encode(tracking)}}. Before raw string literals, this template would have been full of escaped braces.

WebUtility.HtmlEncode() handles a customer name like Jordan & Lee. Encoding is enough for text values. If you ever insert markup that someone else wrote, you'll need a proper sanitizer.

The #shipment-image wrapper has a fixed width and its own pale blue background. We'll capture the wrapper rather than .card, so the 24 pixels of padding around the white card end up in the image. Here's what that looks like rendered with HCTI:

A delivery status card with tracking number, destination and expected delivery date

If you want to compare your template's output against ours, the generated HTML is available to download.

3. Render the HTML to a PNG with PuppeteerSharp

Replace Program.cs with the following, and keep ShipmentTemplate.cs in the project:

using PuppeteerSharp;

var html = ShipmentTemplate.Create(
    "Sam Rivera", "PKG-10482", "Portland, OR", "Friday, October 16");

var installedBrowser = await new BrowserFetcher().DownloadAsync();
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
    Headless = true,
    ExecutablePath = installedBrowser.GetExecutablePath()
});
await using var page = await browser.NewPageAsync();

await page.SetViewportAsync(new ViewPortOptions
{
    Width = 800,
    Height = 600,
    DeviceScaleFactor = 1
});

await page.SetContentAsync(html, new SetContentOptions
{
    WaitUntil = new[] { WaitUntilNavigation.Load },
    Timeout = 30_000
});

// Wait for fonts and <img> elements, with a separate upper limit.
await page.EvaluateFunctionAsync("""
    async () => {
      let timer;
      try {
        await Promise.race([
          Promise.all([
            document.fonts.ready,
            ...[...document.images].map(image => image.decode())
          ]),
          new Promise((_, reject) => {
            timer = setTimeout(() => reject(new Error("Assets timed out")), 30000);
          })
        ]);
      } finally {
        clearTimeout(timer);
      }
    }
    """);

var target = await page.WaitForSelectorAsync(
    "#shipment-image",
    new WaitForSelectorOptions { Visible = true, Timeout = 30_000 });

await target.ScreenshotAsync("shipment.png");

Console.WriteLine(Path.GetFullPath("shipment.png"));

Run it:

dotnet run

The first run is slower because BrowserFetcher.DownloadAsync() has to download Chromium. After that it reuses the existing copy.

SetContentAsync() loads the HTML string directly, so there's no web server involved. The element's ScreenshotAsync() crops to #shipment-image, which is why the PNG is 600 pixels wide even though the viewport is 800.

If you want a sharper image at the same size, set DeviceScaleFactor = 2. You'll get a 1,200-pixel-wide file with exactly the same layout. Changing the viewport width is a different thing. That can change how text wraps and which responsive styles apply.

4. Capture a Whole Page or Wait for Live Data

For the full document instead of just the card, swap the element screenshot for:

await page.ScreenshotAsync("shipment-page.png", new ScreenshotOptions
{
    FullPage = true
});

Without FullPage, you get the viewport. For this card, both options leave a lot of empty white space, which is why the element capture makes more sense. How to Take a Screenshot of a Specific HTML Element goes deeper on crops and padding.

This card doesn't need anything external. If you add a logo or web font, remember that an HTML string loaded into a blank page has no base URL. src="/images/logo.png" won't resolve to your site, so use absolute URLs or embed the asset.

The readiness script above covers the two usual problems:

  • document.fonts.ready waits for the fonts the page uses. It still resolves if a font fails and the browser falls back to another one, so check the output if the typeface matters.
  • image.decode() waits until each <img> can be drawn, and throws if one is broken. If your logo is required, that's what you want.

Neither one knows about data your page fetches after it loads. Say you're capturing a live tracking page that loads the delivery estimate from an API. Navigate to it, then wait for an attribute your app sets once the estimate is on screen:

await page.GoToAsync("https://example.com/tracking/PKG-10482", new NavigationOptions
{
    WaitUntil = new[] { WaitUntilNavigation.DOMContentLoaded },
    Timeout = 30_000
});

await page.WaitForSelectorAsync(
    "[data-shipment-ready='true']",
    new WaitForSelectorOptions { Visible = true, Timeout = 30_000 });

// Run the font/image checks above, then capture #shipment-image.

Waiting for the card itself isn't enough, because the empty shell renders before the estimate arrives. How to Wait for a Page to Finish Rendering covers this pattern in more detail.

Tip

Resist Task.Delay(3000). It adds three seconds even when the card was ready in 200 ms, and it's still too short on the day an API is slow. The 30-second timeouts above are only upper limits. The code moves on as soon as the condition is met.

5. Running This Inside an ASP.NET Core App

The console app launches a browser, takes one screenshot and exits. That's fine for a script, but it falls apart quickly behind an endpoint. Here's what we'd change:

  • Download the browser at deploy time. Run BrowserFetcher as part of your build or container image. If the first request after a deploy has to download Chromium, that user waits for it, and so does every request queued behind them. On Linux, you'll also need Chromium's system libraries in the image.
  • Launch one browser and keep it. Register it as a singleton and create a new page for each render. Starting Chromium takes far longer than opening a page.
  • Limit how many renders run at once. Every page uses real memory. A SemaphoreSlim around the render, or a background queue, keeps a burst of requests from exhausting the server.
  • Return bytes instead of writing files. ScreenshotDataAsync() returns a byte[], which you can send back with Results.File(bytes, "image/png").
  • Ship your fonts. The fonts installed on your Windows machine won't be on a Linux container. If the typeface matters, load it as a web font.

That's the work you take on when you run Chromium yourself. If you don't want it, the next section is the alternative.

6. Render the Same Card with the Typed HTML/CSS to Image Client

HTML/CSS to Image runs the browser for you and returns a hosted image URL. Add the official .NET package:

dotnet add package HtmlCssToImage

Set HCTI_API_ID and HCTI_API_KEY in your environment using the credentials from the dashboard. Keep them on the server and out of anything that ships to the browser, including a Blazor WebAssembly app.

Replace Program.cs again. This version uses the same ShipmentTemplate.cs, without PuppeteerSharp:

using HtmlCssToImage;
using HtmlCssToImage.Models;
using HtmlCssToImage.Models.Requests;

var options = HtmlCssToImageOptions.FromEnvironmentVariables();

using var apiHttp = new HttpClient();
var client = new HtmlCssToImageClient(apiHttp, options);

var request = new CreateHtmlCssImageRequest
{
    Html = ShipmentTemplate.Create(
        "Sam Rivera", "PKG-10482", "Portland, OR", "Friday, October 16"),
    Selector = "#shipment-image",
    ViewportWidth = 800,
    ViewportHeight = 600,
    DeviceScale = 1,
    Format = RenderImageFormat.PNG
};

using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(60));
using var result = await client.CreateImageAsync(request, cancellation.Token);
if (!result.Success || result.Response is null)
{
    throw new InvalidOperationException(
        $"HCTI returned {result.StatusCode}: {result.ErrorDetails?.Message}");
}

Console.WriteLine(result.Response.Url);

// Pass --download to save a copy with a separate, unauthenticated client.
if (args.Contains("--download"))
{
    using var downloadHttp = new HttpClient();
    var png = await downloadHttp.GetByteArrayAsync(
        result.Response.Url, cancellation.Token);
    await File.WriteAllBytesAsync("shipment-hcti.png", png, cancellation.Token);
}

Run dotnet run to print the hosted URL, or dotnet run -- --download to save a local PNG too.

Selector, the viewport and DeviceScale all match the PuppeteerSharp version, so the two images should be identical.

There are two details in that code worth knowing about:

  • using var result is intentional. The typed result holds onto the HTTP response, so it needs disposing. The client README covers Success, Response and ErrorDetails.
  • The download uses its own HttpClient. HtmlCssToImageClient adds your API credentials to the client you give it, and you don't want those sent along with unrelated requests.

In ASP.NET Core, register the client with dependency injection rather than creating HttpClients by hand. The HtmlCssToImage.DependencyInjection package handles that registration.

If your template fetches data or draws a chart, set RenderWhenReady = true and call ScreenshotReady() from the page once it's done. The render_when_ready documentation has the details, including the equivalent for URL screenshots.

7. What About wkhtmltoimage and PhantomJS?

You'll still find older .NET tutorials built on wkhtmltoimage or PhantomJS. wkhtmltoimage is part of wkhtmltopdf, and its status page explains how old its QtWebKit engine is. The PhantomJS repository says development is suspended. If one of them is already running in your app, it's not an emergency. For anything new, I'd start with PuppeteerSharp or an API.

Frequently Asked Questions

Can I Convert a Local HTML File to an Image in C#?

Yes. Read it with File.ReadAllTextAsync("shipment.html") and pass the string to SetContentAsync() or the request's Html property. Relative paths won't resolve from a string, so inline your assets or use absolute URLs. Locally, you can instead navigate PuppeteerSharp to a file: URL so relative paths work. That won't help with the API, since it can't read your disk.

Can I Render a Razor View or Razor Component?

Yes, once it has been rendered to HTML. PuppeteerSharp and HCTI both take the finished markup, not the .cshtml or .razor source. You can also point PuppeteerSharp at a route in your running app. If you render a URL remotely, everything that page loads needs to be reachable from the internet.

For more on the HCTI side, see HTML to PNG in C#, the C# and .NET API examples and the typed .NET client.

Loading newsletter signup…

Please wait a moment.

Have a question?

We'd love to hear about what you're building.

Contact us

Get Started

You'll be up and running in 5 minutes.

Grab an API Key

Keep reading

More posts

View all posts
Get Started Now

NO CREDIT CARD NEEDED. 50 FREE IMAGES EVERY MONTH.