DEV Community

Cover image for Satori's CSS limits: what it can and can't render
Accreditly
Accreditly

Posted on • Originally published at html2img.com

Satori's CSS limits: what it can and can't render

A recent post on the HTML to Image blog looked at why @vercel/og drops emoji from your Open Graph images. That is not a one-off bug. It is a symptom of how the whole thing works. The engine underneath @vercel/og and next/og is Satori, and Satori is not a browser. It converts HTML and CSS into SVG using a flexbox layout engine, and it implements a deliberately small subset of CSS. Emoji is one thing that subset leaves out. This post covers the rest, so you know why a card that looks right in Chrome can break, or quietly render wrong, the moment Satori touches it.

Satori is not a browser

Satori uses the same flexbox layout engine as React Native, the Yoga engine, and it is clear in its own docs that this is not a complete CSS implementation. @vercel/og wraps it: Satori produces the SVG, then it is rasterised to PNG, the whole thing designed to run in an edge function. You can read the supported list in the Satori readme and Vercel's OG image docs.

The mental model that saves you time is this. You are not writing CSS for a browser. You are writing for Yoga plus a curated set of properties. Almost every surprise comes from assuming browser behaviour that Satori never promised.

Everything is flexbox

The headline limit is layout. In Satori, display is either flex or none. There is no block, no inline-block, no table and, the one that catches most people, no grid. Vercel's docs say it plainly: advanced layouts using display: grid will not work.

Because the default is flex and there is no block flow, any element with more than one child has to declare its display explicitly. Leave it out and you get the error every Satori user has seen:

import { ImageResponse } from '@vercel/og'

new ImageResponse(
  <div style={{ color: 'white' }}>
    <h1>Title</h1>
    <p>Subtitle</p>
  </div>
)

// Error: Expected <div> to have explicit "display: flex" or "display: none"
//        if it has more than one child node.
Enter fullscreen mode Exit fullscreen mode

The fix is to say what you mean:

<div style={{ display: 'flex', flexDirection: 'column', color: 'white' }}>
  <h1>Title</h1>
  <p>Subtitle</p>
</div>
Enter fullscreen mode Exit fullscreen mode

CSS Grid is the bigger adjustment. You rebuild every grid as nested flex containers. A two column layout is a row with two half-width children:

<div style={{ display: 'flex' }}>
  <div style={{ display: 'flex', width: '50%' }}>Left</div>
  <div style={{ display: 'flex', width: '50%' }}>Right</div>
</div>
Enter fullscreen mode Exit fullscreen mode

A card grid is a wrapping flex row with fixed-width children:

<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
  <div style={{ display: 'flex', width: '33.33%' }}>Card</div>
  <div style={{ display: 'flex', width: '33.33%' }}>Card</div>
  <div style={{ display: 'flex', width: '33.33%' }}>Card</div>
</div>
Enter fullscreen mode Exit fullscreen mode

Most grid patterns do translate, but you carry the translation in your head and your markup grows a layer of wrapper divs.

Positioning and units are limited too

position supports relative and absolute only. There is no fixed and no sticky, which makes sense because Satori renders to a fixed canvas, not a scrolling viewport. For the same reason, viewport units do not work: vw, vh, vmin and vmax are silently ignored. Size things in pixels and percentages instead.

There is also no z-index. SVG paints in document order, so an element that comes later in the markup sits on top. If you need one thing above another, order the markup that way rather than reaching for a stacking context. Two dimensional transforms work, but three dimensional ones do not.

No selectors, pseudo-elements or media queries

You style with inline styles. There are no stylesheets, no class selectors, no :hover or :focus, no media queries and no ::before or ::after. The pseudo-element one bites hardest in practice, because so many designs lean on ::before for a decorative bar, an overlay or a quotation mark. In Satori you draw those as real elements:

// A ::before accent bar becomes a real child element
<div style={{ display: 'flex', flexDirection: 'column' }}>
  <div style={{ display: 'flex', width: 64, height: 6, background: '#818cf8' }} />
  <h1 style={{ marginTop: 24 }}>Heading</h1>
</div>
Enter fullscreen mode Exit fullscreen mode

@vercel/og lets you use Tailwind through a tw prop, which feels like a way out, but it compiles down to the same inline subset. A class that maps to an unsupported property is still unsupported. Tailwind changes the syntax, not the boundaries.

Fonts and text have their own rules

There are no system fonts. If any text renders, you have to pass the font data yourself as an ArrayBuffer or Buffer:

new ImageResponse(<div style={{ fontFamily: 'Inter' }}>Hello</div>, {
  width: 1200,
  height: 630,
  fonts: [
    { name: 'Inter', data: interArrayBuffer, weight: 600, style: 'normal' },
  ],
})
Enter fullscreen mode Exit fullscreen mode

Satori reads TTF, OTF and WOFF. It does not read WOFF2, which trips people up constantly because Google Fonts serves WOFF2 by default, so a font that loads fine in a browser fails here. Advanced typography such as kerning, ligatures and other OpenType features is not supported, and right-to-left scripts are not handled. Emoji, as the emoji post covered, have to be loaded as graphics through loadAdditionalAsset, because there is no emoji font in the box.

All of this competes for space. @vercel/og runs in an edge function with a 500KB bundle limit, and that budget covers your JSX, CSS, fonts and images. Every extra font weight and emoji asset you load eats into it, so a card with two or three weights and a logo can run out of room faster than you would expect.

The failures are usually silent

The explicit display: flex error is loud. Almost nothing else is. An old-version gap, a vh, a backgroundSize, an em font size: these tend to do nothing at all rather than throw, so the only way to map the edges is trial and error against the OG playground. Satori does give you one debugging aid, a debug flag that draws the layout boxes:

new ImageResponse(element, { width: 1200, height: 630, debug: true })
Enter fullscreen mode Exit fullscreen mode

It is worth saying that the subset has grown. Flexbox gap and background-size: cover work in current versions where they did not a couple of years ago, and CSS custom properties are supported now, including fallbacks and inheritance. The specific gaps move. The model does not. You are maintaining a template in a dialect of CSS whose boundaries you find by hitting them.

What Satori does well

To be fair, the supported subset is enough for a great many cards. Flexbox layout, absolute and relative positioning, padding and margin and gap, borders and border radius, box shadow and opacity, linear and radial gradients, background images by url, two dimensional transforms, text-overflow: ellipsis with line clamping and CSS variables all work. Satori is fast, deterministic and runs at the edge, which is exactly why @vercel/og chose it. If your OG cards live inside that subset, it is a good tool and there is no reason to move.

The limits only bite when you bring a richer browser layout to it and expect the browser to show up.

When to render with a browser instead

If your design needs the parts Satori leaves out, CSS Grid, pseudo-elements, media-query variants, WOFF2 or system fonts, emoji without manual loading, real OpenType typography, then stop fighting the subset and render with a real browser engine. This is the same conclusion the emoji post reached. A browser runs the whole CSS platform, so the card you build in the browser is the card you get back.

The tradeoff is honest. You give up Satori's edge-function model and call a browser instead. The HTML to Image API does that as a hosted request. You POST the HTML and CSS and get a PNG, with grid, pseudo-elements, web fonts and emoji all working, because the render happens in real Chromium:

const res = await fetch('https://app.html2img.com/api/html', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.HTML2IMG_API_KEY,
  },
  body: JSON.stringify({
    html: `<div style="display:grid; grid-template-columns:1fr 1fr; gap:24px;">
             <div>Grid works here</div>
             <div>So do ::before and web fonts</div>
           </div>`,
    width: 1200,
    height: 630,
  }),
})

const { url } = await res.json()
Enter fullscreen mode Exit fullscreen mode

If you are weighing the two for a Next.js project, the walk-through on dynamic OG images in Next.js without @vercel/og sets the API approach next to the Satori one. The Open Graph image template is a ready starting point, and there is a no-code Open Graph image generator if you want to design a card without writing the markup first.


Outgrowing Satori's CSS subset and want OG cards rendered in a full browser engine? Browse the templates gallery or read the docs to get started.

Top comments (0)