DEV Community

Cover image for I generate every blog cover with headless Chrome and a bit of CSS, no design tool
frank chu
frank chu

Posted on Edited on

I generate every blog cover with headless Chrome and a bit of CSS, no design tool

I have not opened a design tool to make a blog cover in months. Every post I publish gets a branded 1000x420 image, and each one is generated by a script that screenshots a styled HTML card with headless Chrome. The cover on this very post was made that way, in about a second, from a title and an accent color.

If you publish anything on a schedule, hand-making cover or social images is exactly the kind of repetitive design work worth deleting. Here is the whole technique.

The trick: headless Chrome screenshots any HTML

You do not need Puppeteer or Playwright for this. The Chrome binary you already have takes a screenshot from the command line, and if you size the page precisely you get an exact-dimension PNG with no cropping:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --disable-gpu --hide-scrollbars \
  --window-size=1000,420 \
  --screenshot=cover.png \
  card.html
Enter fullscreen mode Exit fullscreen mode

--window-size=1000,420 plus a body sized to exactly 1000x420 gives you a 1000x420 image, every time. That is the entire engine. Everything else is just making card.html look good.

The design lives in CSS

Because the canvas is a web page, the whole look is CSS you already know. Gradients, a faint grid overlay, an accent color, a headline, a footer. Here is a trimmed version of the template:

<!doctype html><meta charset="utf-8">
<style>
* { margin:0; box-sizing:border-box; }
body { width:1000px; height:420px; overflow:hidden;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
.card { width:1000px; height:420px; padding:56px 64px; color:#fff;
  display:flex; flex-direction:column; justify-content:space-between;
  background:
    radial-gradient(1200px 500px at 80% -10%, #38bdf822, transparent 60%),
    linear-gradient(135deg,#0a0f1c,#111a2e); }
.kicker { letter-spacing:4px; font-weight:800; color:#38bdf8; }
.title  { font-size:52px; line-height:1.08; font-weight:800; letter-spacing:-1px; }
.meta   { font-family:ui-monospace,Menlo,monospace; color:#93a4c0; }
</style>
<div class="card">
  <div class="kicker">HANDS-ON</div>
  <div class="title">Your headline goes here</div>
  <div class="meta">whattechpost</div>
</div>
Enter fullscreen mode Exit fullscreen mode

That #38bdf822 is the accent color with a low-opacity hex suffix, which is what gives the top corner its soft glow. Swap the accent per topic and every post stays on-brand while still looking distinct.

Making it parametric

The point is not one card, it is a card for every post from a title and a couple of arguments. So the template has placeholders, and a small Python wrapper fills them, writes a temp HTML file, and shoots it:

tsize = 52 if len(title) < 46 else 44 if len(title) < 64 else 38
doc = TEMPLATE.format(kicker=html.escape(kicker),
                      title=html.escape(title), accent=accent, tsize=tsize)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as f:
    f.write(doc); tmp = f.name
subprocess.run([CHROME, "--headless", "--disable-gpu",
    f"--screenshot={out}", "--window-size=1000,420", "--hide-scrollbars", tmp],
    check=True)
Enter fullscreen mode Exit fullscreen mode

The one detail that earns its keep is tsize: the title font shrinks as the title gets longer, so a short punchy headline fills the card and a long one still fits on three lines instead of overflowing. It is a two-line heuristic that removes the single most annoying manual step.

The gotchas I hit

A few things that are not obvious until they bite:

  • HTML-escape the text. A title with an ampersand or an angle bracket will quietly break the layout or the whole render, so run html.escape() on every value you interpolate.
  • System fonts render, web fonts need help. The screenshot fires as soon as the page loads, so a @font-face pulled from the network may not arrive in time. Stick to system fonts, or preload and add a small delay. The system stack above needs nothing.
  • Size the body, do not crop. Exact dimensions from --window-size plus a body of the same size beats screenshotting something bigger and cropping, with no off-by-a-pixel edges.
  • Keep --hide-scrollbars on. Without it, content that is even slightly too tall bakes a scrollbar into the image.

Why this beats a design tool here

For a one-off hero image, open a real design tool. For images you make on every post, this wins on the things that compound: it is versioned in git, it diffs, it regenerates in CI, it never drifts off-brand, and it costs zero minutes per image after the template exists. I changed my accent palette once and every future cover inherited it.

It also pairs with the rest of an automated publishing flow. I wrote yesterday about the walls I hit pushing posts to dev.to through its API; this is the piece that makes each of those posts show up with a cover without me touching a canvas.

The same engine does charts, not just covers. I pulled that into its own piece: a JSON spec in, a branded bar-chart PNG out, no charting library.


If you generate your own social or cover images, I would like to see your template, because the design is the fun part and I am always looking to steal a better gradient.

Top comments (2)

Collapse
 
alexandersstudi profile image
Alexander

Bypassing Puppeteer to hit the Chrome binary directly is a massive time saver for simple automation tasks. One small trick to improve the output is adding . device-scale-factor=2 to your command line arguments. That forces a high-DPI screenshot so your typography stays perfectly crisp on retina displays, avoiding the need to mess with CSS transforms or double the window size manually.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.