Compositions
Compositions are TSX files that describe your image layout. They use a custom JSX runtime — not React — so there are no hooks, no state, and no effects. Just pure functions that return markup.
Overview
Every composition exports two things:
import type { CompositionConfig } from 'grafex';
// 1. Config — sets output dimensions
export const config: CompositionConfig = {
width: 1200,
height: 630,
};
// 2. Default export — the image layout
export default function MyImage() {
return <div style={{ color: 'white' }}>Hello</div>;
}The config is optional. If omitted, Grafex defaults to 1200×630.
The JSX Runtime
Grafex provides its own h() and Fragment functions. You do not need to import React or configure a JSX pragma — Grafex handles the transform automatically via esbuild.
Your components are called once to produce HTML. There is no virtual DOM, no reconciliation, no component lifecycle. Think of compositions as template functions, not interactive components.
What works
- HTML elements (
div,span,img,svg) - Inline styles via
styleprop - Children, nesting, and composition
- Conditional rendering
- Array mapping
- Fragment syntax (
<>...</>)
Does not apply
useState,useEffect, or any hooks- Event handlers (
onClick, etc.) - Client-side interactivity
CompositionConfig
interface CompositionConfig {
width?: number; // Image width in pixels (default: 1200)
height?: number; // Image height in pixels (default: 630)
format?: 'png' | 'jpeg'; // Output format (default: 'png')
quality?: number; // JPEG quality 1-100 (default: 90)
fonts?: string[]; // URLs to font stylesheets
css?: string[]; // CSS file paths to inject
scale?: number; // Device pixel ratio (default: 1)
variants?: Record<string, VariantConfig>; // Named output variants
htmlAttributes?: Record<string, string>; // Attributes on the root <html> element
}Dimensions can also be overridden at export time via CLI flags (--width, --height) or API options. CLI/API values take precedence over the config export.
scale sets the device pixel ratio for rendering. The default is 1. Set it to 2 for retina-quality output — the layout stays the same, but the image is rendered at double the pixel density. A 1200x630 composition at scale: 2 outputs a 2400x1260 PNG. Accepts any positive number, including decimals like 1.5 or 0.5. Like width and height, scale can be overridden at export time via the --scale CLI flag or the scale option in the API.
Custom Fonts
Load external fonts by providing URLs in config.fonts. Grafex fetches each URL before rendering, so Google Fonts and any other CSS font URL work out of the box.
import type { CompositionConfig } from 'grafex';
export const config: CompositionConfig = {
width: 1200,
height: 630,
fonts: [
'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap',
],
};
export default function Card() {
return (
<h1 style={{ fontFamily: "'Playfair Display', serif" }}>Hello</h1>
);
} Tip:
Any CSS font URL works — Google Fonts, Adobe Fonts, or a self-hosted stylesheet. Grafex injects each URL as a <link> element before rendering.
CSS Files
Load external CSS files by specifying paths in config.css. Paths are resolved relative to the composition file. The CSS is injected as <style> tags in the HTML <head> before rendering.
import type { CompositionConfig } from 'grafex';
export const config: CompositionConfig = {
width: 1200,
height: 630,
css: ['./styles.css'],
};
export default function Card() {
return (
<div className="card">
<h1 className="title">Hello</h1>
</div>
);
} Tip:
This is a generic file injection mechanism — it works with plain CSS, Tailwind output, Sass output, or any other .css file. Grafex doesn't care how the CSS was generated.
Tailwind CSS
Run Tailwind's CLI to compile your stylesheet, then reference the output in config.css. In dev mode, Tailwind's --watch flag recompiles on every change, and Grafex detects the updated CSS file and re-renders automatically.
See the Tailwind CSS guide for the full dev workflow, npm scripts, and a working example.
npx tailwindcss -i ./input.css -o ./styles.cssimport type { CompositionConfig } from 'grafex';
export const config: CompositionConfig = {
width: 1200,
height: 630,
css: ['./styles.css'],
};
export default function Card() {
return (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
<h1 className="text-6xl font-bold">Hello Tailwind</h1>
</div>
);
}HTML Attributes
Set arbitrary attributes on the root <html> element via config.htmlAttributes. This enables CSS selectors like :root[data-theme="dark"] used by Tailwind v4 themes and other theming systems.
import type { CompositionConfig } from 'grafex';
export const config: CompositionConfig = {
width: 1200,
height: 630,
htmlAttributes: {
'data-theme': 'dark',
lang: 'en',
},
};
export default function Card() {
return (
<div className="w-full h-full flex items-center justify-center">
<h1>Hello</h1>
</div>
);
}This renders as <html data-theme="dark" lang="en">. Attribute values are HTML-escaped automatically.
Variants:
When a variant sets htmlAttributes, its entries are spread over the base config's attributes. This means you can override individual keys per variant without repeating the rest.
Images
Use local image files in <img> tags or CSS background-image. Grafex reads them from disk and embeds them as base64 data URLs automatically — no server or public URL needed.
export default function Card() {
return (
<div style={{ width: '100%', height: '100%' }}>
<img src="./logo.png" alt="Logo" width="200" height="60" />
</div>
);
}Paths are resolved relative to the composition file. Supported formats: PNG, JPEG, GIF, WebP, SVG, AVIF, ICO, BMP. Remote URLs (http://, https://) and data URLs are passed through unchanged.
CSS url() references work too — both in inline styles and in external CSS files loaded via config.css:
.hero {
background-image: url('./hero.jpg');
}Styling
Style compositions using the style prop with a CSS-in-JS object:
<div
style={{
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(135deg, #667eea, #764ba2)',
padding: '40px',
gap: '16px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.3)',
}}
>
{/* content */}
</div> Tip:
Unlike Satori, Grafex supports the full CSS spec because it renders in a real browser engine. Flexbox, Grid, calc(), CSS variables, z-index, gradients, shadows, transforms — it all works.
Props
Compositions can accept props for dynamic content. Pass them via the CLI (--props) or the API (options.props).
interface Props {
title: string;
date: string;
}
export default function BlogCard({ title, date }: Props) {
return (
<div style={{ padding: '80px', ... }}>
<div style={{ fontSize: '48px' }}>{title}</div>
<div style={{ color: '#94a3b8' }}>{date}</div>
</div>
);
}Export with props:
grafex export -f blog-card.tsx -o card.png --props '{"title":"My Post","date":"March 2026"}'Example: OG Card
Here's a complete OG image composition and its rendered output. This is the kind of thing you'd generate at build time for every blog post.
export const config = {
width: 600,
height: 400,
};
export default function OgCard() {
return (
<div style={{
width: '100%',
height: '100%',
background: 'linear-gradient(145deg, #0f172a 0%, #1e293b 100%)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '52px',
fontFamily: 'system-ui, -apple-system, sans-serif',
position: 'relative',
overflow: 'hidden',
}}>
{/* Top accent glow */}
<div style={{
position: 'absolute',
top: '-60px',
right: '-60px',
width: '300px',
height: '300px',
background: 'radial-gradient(circle, rgba(244,114,182,0.2) 0%, transparent 70%)',
}} />
{/* Top: tag */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', zIndex: 1 }}>
<div style={{
background: 'rgba(163,230,53,0.15)',
border: '1px solid rgba(163,230,53,0.3)',
borderRadius: '6px',
padding: '4px 12px',
color: '#A3E635',
fontSize: '12px',
fontWeight: '600',
letterSpacing: '0.08em',
textTransform: 'uppercase',
}}>
Tutorial
</div>
<span style={{ color: '#475569', fontSize: '13px' }}>5 min read</span>
</div>
{/* Middle: title */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', zIndex: 1 }}>
<div style={{
color: '#F1F5F9',
fontSize: '36px',
fontWeight: '700',
lineHeight: '1.2',
letterSpacing: '-0.5px',
}}>
Building Modern APIs
</div>
<div style={{ color: '#94A3B8', fontSize: '16px', lineHeight: '1.5' }}>
Best practices for REST and GraphQL in 2026
</div>
</div>
{/* Bottom: author + meta */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #F472B6, #38BDF8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '14px',
fontWeight: '700',
flexShrink: 0,
}}>
JS
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span style={{ color: '#E2E8F0', fontSize: '14px', fontWeight: '600' }}>Jane Smith</span>
<span style={{ color: '#64748B', fontSize: '12px' }}>March 15, 2026</span>
</div>
</div>
<div style={{
color: '#475569',
fontSize: '13px',
fontWeight: '600',
letterSpacing: '0.05em',
}}>
dev.blog
</div>
</div>
{/* Bottom-left accent stripe */}
<div style={{
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
height: '3px',
background: 'linear-gradient(90deg, #F472B6, #38BDF8, #A3E635)',
}} />
</div>
);
}
Custom Components
Compositions can import and use components from other files. Grafex bundles everything with esbuild before rendering.
interface BadgeProps {
label: string;
color: string;
}
export function Badge({ label, color }: BadgeProps) {
return (
<span style={{ background: color, padding: '4px 12px', ... }}>
{label}
</span>
);
}import type { CompositionConfig } from 'grafex';
import { Badge } from './components/badge';
export const config: CompositionConfig = { width: 1200, height: 630 };
export default function OgImage() {
return (
<div style={{ background: '#1e293b', ... }}>
<Badge label="New" color="#a3e635" />
<div style={{ fontSize: '48px' }}>Grafex v0.1</div>
</div>
);
}Variants
Produce multiple outputs from a single composition — different sizes, formats, or props. Define a variants record in your config. Each variant inherits from the base config and can override any field.
import type { CompositionConfig } from 'grafex';
export const config: CompositionConfig = {
width: 1200,
height: 630,
variants: {
og: {},
twitter: { height: 675 },
square: { width: 1080, height: 1080, props: { layout: 'square' } },
},
};
export default function Card({ layout = 'default' }: { layout?: string }) {
return (
<div style={{ width: '100%', height: '100%', background: '#1e293b', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '48px' }}>
{layout}
</div>
);
}CLI usage
# Renders og.png, twitter.png, square.png (named after each variant)
grafex export -f card.tsx
# Same, but into a directory
grafex export -f card.tsx -o ./images/
# Export a single variant
grafex export -f card.tsx --variant twitter -o twitter.pngAPI usage
import { render, renderAll, close } from 'grafex';
import { writeFileSync } from 'node:fs';
// Single variant
const twitter = await render('./card.tsx', { variant: 'twitter' });
writeFileSync('twitter.png', twitter.buffer);
// All variants
const all = await renderAll('./card.tsx', { props: { title: 'Hello' } });
for (const [name, result] of all) {
writeFileSync(`${name}.${result.format}`, result.buffer);
}
await close(); Merge rules:
CLI/API options override variant config, which overrides base config. Props are shallow-merged: variant props apply first, then CLI/API props override individual keys. Array fields (fonts, css) replace rather than merge. htmlAttributes are shallow-merged: variant attributes are spread over base config attributes.