Back to Blog Overview

Adding Browser Frames to Your Screenshots

Published on 18.03.2026

When you take a screenshot of a website using automation tools like Playwright or Selenium, the engine gives you exactly what is in the viewport: the website content itself.

However, many of our users want their screenshots to look "real." Whether it is for a landing page, a presentation, or a portfolio, a screenshot often looks better when it is wrapped in a browser window with buttons, a URL bar, and rounded corners.

The Challenge: There is no "Include Browser" flag

Scroll down to learn more ⬇️

Because Playwright interacts directly with the browser engine, there is no simple setting to "capture the Chrome window." To solve this, I decided to build a custom "wrapper" using Python and the Pillow (PIL) library.

Instead of just saving the raw image, our API now:

  1. Calculates the new dimensions needed for a header.
  2. Creates a "canvas" with rounded top corners.
  3. Draws the classic Safari-style interface (the "traffic light" buttons, URL bar, and icons) using code.
  4. Pastes your website screenshot directly into that frame.

Added browser frame around a screenshot taken from Playwright/Python

The Code Behind the Frame

Below is the logic we use to "fake" the browser interface. We use ImageDraw to manually render the buttons and the URL field.

# --- helper functions ---
def prettify_url(url: str) -> str:
    for prefix in ("https://", "http://"):
        if url.startswith(prefix):
            url = url[len(prefix):]
            break
    return url.rstrip("/")



def add_safari_frame(input_path, output_path, url):
    screenshot = Image.open(input_path).convert("RGB")
    width, height = screenshot.size

    topbar_height = 78
    corner_radius = 16

    outer_bg = (242, 242, 242)
    toolbar_bg = (246, 246, 246)
    border = (210, 210, 210)
    url_bg = (255, 255, 255)
    text_color = (95, 95, 95)
    icon_color = (120, 120, 120)

    total_height = height + topbar_height
    canvas = Image.new("RGBA", (width, total_height), (0, 0, 0, 0))

    # Main browser body with rounded top corners
    browser_layer = Image.new("RGBA", (width, total_height), (0, 0, 0, 0))
    browser_draw = ImageDraw.Draw(browser_layer)

    browser_draw.rounded_rectangle(
        (0, 0, width - 1, total_height - 1),
        radius=corner_radius,
        fill=outer_bg + (255,),
        outline=None
    )

    # Toolbar clipped into rounded outer shape
    browser_draw.rectangle((0, 0, width, topbar_height), fill=toolbar_bg + (255,))
    browser_draw.line((0, topbar_height - 1, width, topbar_height - 1), fill=border + (255,), width=1)

    # Paste screenshot below toolbar
    browser_layer.paste(screenshot.convert("RGBA"), (0, topbar_height))

    # Shadow under toolbar
    shadow_layer = Image.new("RGBA", (width, total_height), (0, 0, 0, 0))
    shadow_draw = ImageDraw.Draw(shadow_layer)
    shadow_y = topbar_height - 1
    shadow_draw.rectangle((0, shadow_y, width, shadow_y + 6), fill=(0, 0, 0, 35))
    shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=4))

    canvas.alpha_composite(browser_layer)
    canvas.alpha_composite(shadow_layer)

    draw = ImageDraw.Draw(canvas)

    def draw_chevron(x, y, direction="left", size=8, color=(120, 120, 120), width_line=2):
        if direction == "left":
            draw.line((x + size, y - size, x, y), fill=color, width=width_line)
            draw.line((x, y, x + size, y + size), fill=color, width=width_line)
        else:
            draw.line((x, y - size, x + size, y), fill=color, width=width_line)
            draw.line((x + size, y, x, y + size), fill=color, width=width_line)

    def draw_plus(x, y, size=7, color=(120, 120, 120), width_line=2):
        draw.line((x - size, y, x + size, y), fill=color, width=width_line)
        draw.line((x, y - size, x, y + size), fill=color, width=width_line)

    def draw_share_icon(x, y, w=16, h=14, color=(120, 120, 120), width_line=2):
        draw.rounded_rectangle(
            (x - w // 2, y - h // 2 + 4, x + w // 2, y + h // 2),
            radius=3,
            outline=color,
            width=width_line
        )
        draw.line((x, y + 2, x, y - 8), fill=color, width=width_line)
        draw.line((x, y - 8, x - 4, y - 4), fill=color, width=width_line)
        draw.line((x, y - 8, x + 4, y - 4), fill=color, width=width_line)

    def draw_download_icon_safari(x, y, color=(120, 120, 120), width_line=2):
        r = 11
        draw.ellipse((x - r, y - r, x + r, y + r), outline=color, width=width_line)
        draw.line((x, y - 5, x, y + 4), fill=color, width=width_line)
        draw.line((x, y + 4, x - 4, y), fill=color, width=width_line)
        draw.line((x, y + 4, x + 4, y), fill=color, width=width_line)

    def fit_text(text, font, max_width):
        if draw.textlength(text, font=font) <= max_width:
            return text
        ellipsis = "..."
        low, high = 0, len(text)
        while low < high:
            mid = (low + high) // 2
            candidate = text[:mid].rstrip() + ellipsis
            if draw.textlength(candidate, font=font) <= max_width:
                low = mid + 1
            else:
                high = mid
        return text[:max(0, low - 1)].rstrip() + ellipsis

    # Fonts
    try:
        font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 16)
    except:
        try:
            font = ImageFont.truetype("DejaVuSans.ttf", 24)
        except:
            font = ImageFont.load_default()

    cy = 39

    # --- left traffic lights ---
    dot_r = 6
    dot_x = 20
    dot_y = cy - dot_r
    colors = [(255, 95, 86), (255, 189, 46), (39, 201, 63)]
    for i, c in enumerate(colors):
        x = dot_x + i * 18
        draw.ellipse((x, dot_y, x + 2 * dot_r, dot_y + 2 * dot_r), fill=c)

    # More spacing after traffic lights
    nav_start_x = 90
    draw_chevron(nav_start_x, cy, "left", size=7, color=icon_color, width_line=2)
    draw_chevron(nav_start_x + 24, cy, "right", size=7, color=icon_color, width_line=2)

    # --- right icons ---
    # order: download, share, plus
    right_margin = 22
    icon_gap = 34

    x_plus = width - right_margin - 6
    x_share = x_plus - icon_gap
    x_download = x_share - icon_gap

    draw_download_icon_safari(x_download, cy, color=icon_color, width_line=2)
    draw_share_icon(x_share, cy, color=icon_color, width_line=2)
    draw_plus(x_plus, cy, size=6, color=icon_color, width_line=2)

    # --- URL field ---
    url_left = 145
    url_right = x_download - 26
    url_top = cy - 12
    url_bottom = cy + 12

    draw.rounded_rectangle(
        (url_left, url_top, url_right, url_bottom),
        radius=12,
        fill=url_bg,
        outline=border,
        width=1
    )

    # Lock icon
    lock_x = url_left + 14
    lock_y = cy
    draw.arc((lock_x - 4, lock_y - 9, lock_x + 4, lock_y - 1), start=180, end=360, fill=icon_color, width=2)
    draw.rounded_rectangle((lock_x - 5, lock_y - 2, lock_x + 5, lock_y + 7), radius=2, outline=icon_color, width=2)

    # URL text
    shown_url = fit_text(prettify_url(url), font, url_right - (lock_x + 14) - 12)
    draw.text((lock_x + 14, cy - 8), shown_url, fill=text_color, font=font)

    # Final save as RGB
    canvas.convert("RGB").save(output_path, quality=95)

Why do it this way?

By drawing the interface with code rather than using a static background image, the browser frame stays perfectly crisp regardless of the screenshot resolution. Whether you are taking a 1280px or 4K screenshot, the buttons and URL text will always be pixel-perfect.

Get started with our Website Screenshot API on AWS or Azure Marketplace:

👉 AWS users: aws3.link/WebsiteScreenshotAPI

👉 Azure users: azr.link/WebsiteScreenshotAPI