Skip to content
Grafex
GitHub

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:

example.tsx
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 style prop
  • 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

typescript
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.

card.tsx
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.

card.tsx
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.

bash
npx tailwindcss -i ./input.css -o ./styles.css
card.tsx
import 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.

card.tsx
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.

card.tsx
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:

styles.css
.hero {
  background-image: url('./hero.jpg');
}

Styling

Style compositions using the style prop with a CSS-in-JS object:

example.tsx
<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).

blog-card.tsx
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:

bash
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.

og-card.tsx
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>
  );
}
og-card.png 600 × 400
Rendered OG card: Building Modern APIs

Custom Components

Compositions can import and use components from other files. Grafex bundles everything with esbuild before rendering.

components/badge.tsx
interface BadgeProps {
  label: string;
  color: string;
}

export function Badge({ label, color }: BadgeProps) {
  return (
    <span style={{ background: color, padding: '4px 12px', ... }}>
      {label}
    </span>
  );
}
og-image.tsx
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.

card.tsx
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

bash
# 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.png

API usage

typescript
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.