Skip to content
← Back to blog
Frontend Engineering

How to export a responsive Astro campaign as a PNG with a custom aspect ratio

9 min read

Written by: Jonathan Reis on

A marketing page can become a shareable image without a fixed social preset. Let the person preparing the asset choose a ratio, review the exact capture area, and export that rendered surface.

Updated:

OpenGraph preview image for this article. How to export a responsive Astro campaign as a PNG with a custom aspect ratio

A static marketing page is often the fastest place to compose a campaign creative. It already has the screenshots, type, spacing, translation, and product copy that need to stay in sync. The missing piece is practical: someone needs a PNG for a community post or social feed and should not have to rebuild the same composition in a design tool after every copy change.

The tempting implementation is a button that always exports 1080 × 1350. That works for one destination, then becomes a constraint. A community card may need a wide image, while another surface needs a portrait asset. Changing CSS by hand, taking another screenshot, and hoping the resulting file has the intended proportions is not a reliable production step.

The useful contract is simple: the capture area has an editable aspect ratio, the page shows that boundary, and the downloaded PNG uses the dimensions of that rendered element. The editor can type 1.57, 5:4, or 4:5, inspect the composition, and export the same surface without a second resize step.

Make the capture ratio part of the editing workflow

The export target should be the creative itself, not the whole document. Keep ratio controls, the export button, and status text outside that element so they cannot appear in the downloaded image. A visible frame around the target makes the boundary clear while the page is being edited; the frame belongs outside the target too.

The input accepts either a decimal ratio or a width-to-height expression. Normalize a comma decimal separator for locales where it is natural to type 1,57, then apply the result as a CSS custom property on the frame:

function parseAspectRatio(value: string) {
  const normalizedValue = value.trim().replace(",", ".");
  const parts = normalizedValue.split(":").map(Number);
  const ratio = parts.length === 2 ? parts[0] / parts[1] : Number(normalizedValue);

  return Number.isFinite(ratio) && ratio > 0 ? ratio : null;
}

function applyAspectRatio(frame: HTMLElement, value: string) {
  const ratio = parseAspectRatio(value) ?? 1.57;
  frame.style.setProperty("--marketing-aspect-ratio", String(ratio));
}

The target uses that variable through aspect-ratio: var(--marketing-aspect-ratio). Its width remains responsive to the available page space; its height follows from the selected ratio. Invalid input should not collapse the creative. Keep the last usable default and mark the field invalid so the person editing it can correct the value.

This makes the preparation flow explicit:

  1. Enter the required aspect ratio.
  2. Review the composition inside the capture frame.
  3. Read the rendered PNG dimensions.
  4. Export.

There is no hidden crop after review. The image is the layout that was inspected.

Show the size before the download

Showing dimensions after a download is too late. The person choosing an output size needs the information while adjusting the viewport, not after creating a file they may discard.

A ResizeObserver keeps the label aligned with the target instead of a guessed list of social-media presets. Measure the actual box immediately before export and when it resizes:

function getExportDimensions(target: HTMLElement) {
  const bounds = target.getBoundingClientRect();
  return {
    width: Math.round(bounds.width),
    height: Math.round(bounds.height),
  };
}

const updateDimensions = () => {
  const { width, height } = getExportDimensions(target);
  dimensionsLabel.textContent = `${width} × ${height}px`;
};

updateDimensions();
new ResizeObserver(updateDimensions).observe(target);

For this kind of framed creative, getBoundingClientRect().height is more accurate than scrollHeight: the aspect-ratio box is the intentional output boundary. scrollHeight measures overflowing content, which is useful for a full scrolling document but can turn a fixed campaign card into a file with an unintended height. The label turns a layout change into a decision before a file reaches the Downloads folder.

Rasterize the rendered target, not a copied off-screen page

We used html-to-image to convert the target element into a PNG data URL. The core call is small:

const { width, height } = getExportDimensions(target);
const dataUrl = await toPng(target, {
  backgroundColor: "#f6f2ea",
  canvasWidth: width,
  canvasHeight: height,
  width,
  height,
  pixelRatio: 1,
  cacheBust: true,
});

The details around that call matter more than the call itself.

Our first version cloned the page, forced the clone to a canonical width, and positioned it far outside the viewport. That looked attractive because it made the output independent from the visible page. It also produced blank PNGs in Chrome. The rasterizer relies on browser paint behavior for its foreignObject-based path; a node placed thousands of pixels outside the renderable area is not a reliable capture source.

The second issue was subtler. A cloned element creates new image elements. Even when the original screenshots were visible, the cloned images could still be loading when export began. The output had the right dimensions but missing content.

Capturing the already-rendered target removes both sources of uncertainty. It also makes the result match the visible capture surface, which is the point of this workflow.

Wait for fonts and screenshots

Do not assume that a visible page is ready to rasterize. A font swap can change line breaks, and an image can be decoded after the rest of the layout appears. Wait for both before calling toPng:

await document.fonts.ready;

await Promise.all(
  [...target.querySelectorAll("img")].map(async (image) => {
    if (!image.complete) {
      await new Promise<void>((resolve, reject) => {
        image.addEventListener("load", () => resolve(), { once: true });
        image.addEventListener("error", () => reject(new Error("Image failed to load")), {
          once: true,
        });
      });
    }

    await image.decode();
  }),
);

image.complete only says that loading finished. It does not guarantee that the image is ready for paint. decode() closes that gap. If an image cannot load, reject the export and show a useful retry message rather than downloading a broken creative that looks successful at a glance.

Keep export controls outside the captured element

The target should contain only the artwork. Put the button, status text, debug aids, and device-mode instructions next to it or in a fixed overlay outside the capture root. This has two benefits: the PNG remains clean, and the page can keep useful developer controls without needing CSS tricks to hide them during export.

Use a disabled button while conversion is running. Rasterizing a tall page is asynchronous, and a second click can create a duplicate download or compete for the same temporary browser resources. Restore the original label in finally, whether the conversion succeeds or fails.

Verify pixels, not only file dimensions

A downloaded file with the expected width and height is not proof that export worked. We saw a blank image that measured correctly because the canvas itself was created successfully while its content was not painted.

The practical verification loop is short:

  • Export a wide ratio such as 1.57 and a portrait ratio such as 4:5.
  • Open both files and verify that screenshots, text, and the final footer are present.
  • Confirm the dimensions shown before export match the downloaded file.
  • Check that each ratio is recomposed rather than being a scaled version of another card.

For a community launch, the creative itself is part of the product explanation. Our guide to launching an app on Reddit with organic engagement and honest measurement covers the other side of that work: choose a community that accepts the format, use a real product image, and make a specific request for feedback. A responsive PNG button does not make a post good. It removes one avoidable source of friction from preparing it.

Export checklist

  • Keep the export target separate from controls.
  • Parse and validate a decimal or width:height aspect-ratio value.
  • Make the visible target boundary define the PNG dimensions.
  • Update the displayed dimensions with ResizeObserver.
  • Wait for fonts and call decode() on screenshots.
  • Test content pixels, not just PNG metadata.

The key decision is to make the capture surface the source of truth. If the editor chooses 4:5, the frame, the size label, and the downloaded PNG should all describe that same card. That leaves browser-side export as a small, inspectable step rather than a second design system hidden behind a download button.

Related postHow to launch an app on Reddit with organic engagement and honest measurement10 min readWritten by: Jonathan Reis on