Skip to main content

Installation

npm install @gravity-ai/ui
Set GRAVITY_API_KEY in your server environment.

How the pieces fit together

1

Publisher — drop in the component

Place <GravityAd /> where the ad should appear. For a ShadCN/Tailwind app that’s the entire integration.
2

Client — measure and fetch

On mount, GravityAd measures its container with a ResizeObserver, sends the dimensions to the engine, and fetches the ad for the placement.
3

Gravity — return ad + renderer

The engine returns the ad object with a renderer_spec: a JSON layout tree describing the full unit. We can change that spec server-side at any time.
4

Client — render

The SDK’s runtime interprets the spec into DOM, fires the impression pixel on visibility, and routes clicks through the tracked URL.
Because the layout lives in the response, Gravity ships a new ad design from the server — no publisher code change, no SDK upgrade.

Render ads

import { GravityAd } from '@gravity-ai/ui';

function ChatResponse({ response }) {
  return (
    <div>
      <p>{response}</p>
      <GravityAd placement="below_response" apiKey={process.env.GRAVITY_API_KEY} />
    </div>
  );
}
PropTypeDescription
placementstringPlacement identifier (required).
apiKeystringPublisher API key. Defaults to GRAVITY_API_KEY.
adGravityAdDataPass ad data directly instead of fetching.
variantGravityVariantHard-pin a bundled layout, overriding the server spec. Omit to let Gravity drive the render.
querystringUser query/context for contextual serving.
sessionIdstringStable session ID so a user sees a consistent render within a session.
showLabelbooleanShow the “Ad”/sponsored label.
onLoad / onEmpty / onClickcallbacksLifecycle hooks. onLoad includes measured dimensions.
The component handles impression tracking automatically via IntersectionObserver.

Theme

ShadCN/Tailwind apps need nothing — the ad reads their existing CSS variables and follows light/dark mode. Other apps wrap once with GravityThemeProvider:
import { GravityThemeProvider } from '@gravity-ai/ui';

<GravityThemeProvider theme={{ primary: '330 81% 60%', radius: '0.75rem' }}>
  <App />
</GravityThemeProvider>

Renderer spec

The ad response carries a renderer_spec — the JSON tree the SDK renders. Render precedence:
  1. Publisher variant prop — a hard pin. Always wins.
  2. Server renderer_spec — the normal path. Gravity ships the look remotely.
  3. Bundled fallback — the SDK’s built-in, dimension-aware look. Used only when neither above is present, so the unit always renders something sensible with zero config.
Whether an ad response carries a renderer_spec at all is decided server-side, per placement: Gravity attaches the spec when the placement has a default design configured on our end. There is no request parameter to enable it — see how to get one set up. A spec for a single line of text:
{
  "version": 1,
  "name": "text-line",
  "root": {
    "type": "link",
    "style": { "display": "inline", "fontSize": "14px", "color": "hsl(var(--muted-foreground))" },
    "children": [
      { "type": "text", "text": "↗ ", "style": { "color": "hsl(var(--primary))", "fontWeight": 600 } },
      { "type": "text", "bind": "title", "style": { "color": "hsl(var(--foreground))", "fontWeight": 500 } },
      { "type": "text", "bind": "cta", "style": { "color": "hsl(var(--primary))", "fontWeight": 600 } }
    ]
  }
}

Node types

typeRenders asNotes
box<div>Layout container. Use style for flex/grid/positioning.
text<span>Content from bind, static text, or a {field} template.
image<img>src from bind or a static src. URL-validated.
link<a>Wraps children. Defaults to the ad’s tracked click URL.
Every node also supports:
  • bind — pulls a value from the ad payload. Allowed: adText, title, cta, brandName, url, favicon.
  • text — static text with {field} placeholders (e.g. "Visit {brandName}").
  • showIf — render this node only when the given ad field is non-empty.
  • style — inline CSS (see below).

Test a spec locally

Pass the ad prop and GravityAd skips the network fetch and renders exactly what you give it — including any renderer_spec. Use this to preview a unit before it goes live or to integration-test without hitting the engine.
import { GravityAd } from '@gravity-ai/ui';

const previewAd = {
  title: 'Build AI agents faster',
  cta: 'Try it free',
  brandName: 'Vercel',
  url: 'https://vercel.com',
  adText: 'Vercel AI SDK',
  renderer_spec: {
    version: 1,
    name: 'text-line',
    root: {
      type: 'link',
      style: { display: 'inline', fontSize: '14px' },
      children: [
        { type: 'text', bind: 'title', style: { color: 'hsl(var(--foreground))', fontWeight: 500 } },
        { type: 'text', bind: 'cta', style: { color: 'hsl(var(--primary))', fontWeight: 600 } },
      ],
    },
  },
};

<GravityAd placement="below_response" ad={previewAd} />
Swap the renderer_spec to see any layout render against your live theme — same rendering path as production. Leave impUrl/clickUrl off the mock ad and no impression or click tracking fires, so it’s a pure visual preview.
renderer_spec accepts the spec object or a JSON string, so you can paste a spec straight from a file or API response.

Styling

The style object is plain CSS with camelCase keys. Two ways to style, mix freely. Reference theme tokens for a native look that follows the publisher’s light/dark mode:
{
  "type": "box",
  "style": {
    "background": "hsl(var(--card))",
    "color": "hsl(var(--card-foreground))",
    "border": "1px solid hsl(var(--border))",
    "borderRadius": "var(--radius)",
    "padding": "16px"
  }
}
Common tokens: --background, --foreground, --card, --primary, --muted-foreground, --accent, --border, --radius. Hardcode values for pixel-perfect control. Any CSS property works:
{
  "type": "text",
  "bind": "brandName",
  "style": { "display": "block", "fontSize": "56px", "fontWeight": 800, "textTransform": "uppercase", "color": "#ff2d92" }
}
Lay the unit out with theme tokens so it feels native, then hardcode the one or two properties (a brand color, a font size) where you want exact control.

Best practices

A few rules keep server-rendered ads native-feeling and safe to evolve.

The spec owns the whole unit; data binds per-ad

A renderer_spec describes the entire ad container, not a patch over your own markup — the runtime renders the full tree from root down. What stays dynamic is the content: bind a text/image node to an ad field and the same spec re-renders correctly for every different ad the engine serves. So you design the look once; the words, images, and links come from the live ad. Use showIf so optional parts (e.g. a brand image row) disappear gracefully when a field is empty.

Lay it out with tokens, hardcode only what must be locked

Write layout, color, radius, and type as theme tokens (hsl(var(--primary)), var(--radius)). The ad then inherits the host app’s styling at paint time — when a publisher restyles their site or flips dark mode, the ad follows with no spec change. Reserve hardcoded values for the one or two properties you want pixel-locked (a brand color, a fixed font size). Rule of thumb: token = inherits the publisher’s styling, hardcoded = locked.

Start from a safe default card

A good starting spec is a minimal, fully tokenized card — structure fixed, everything else inherited — which looks native out of the box and is hard to make ugly:
{
  "version": 1,
  "name": "default-card",
  "root": {
    "type": "link",
    "style": { "display": "block", "background": "hsl(var(--card))", "color": "hsl(var(--card-foreground))", "border": "1px solid hsl(var(--border))", "borderRadius": "var(--radius)", "padding": "16px" },
    "children": [
      { "type": "box", "style": { "display": "flex", "alignItems": "center", "gap": "8px", "marginBottom": "8px" }, "children": [
        { "type": "image", "bind": "favicon", "showIf": "favicon", "style": { "width": "16px", "height": "16px", "borderRadius": "4px" } },
        { "type": "text", "bind": "brandName", "style": { "fontSize": "13px", "color": "hsl(var(--muted-foreground))" } }
      ] },
      { "type": "text", "bind": "title", "style": { "display": "block", "fontSize": "16px", "fontWeight": 600 } },
      { "type": "text", "bind": "adText", "showIf": "adText", "style": { "display": "block", "fontSize": "14px", "color": "hsl(var(--muted-foreground))", "marginTop": "4px" } },
      { "type": "text", "bind": "cta", "style": { "display": "inline-block", "marginTop": "12px", "fontSize": "14px", "fontWeight": 600, "color": "hsl(var(--primary))" } }
    ]
  }
}

Building your own renderer? Match these impression rules

If you interpret renderer_spec with your own runtime instead of @gravity-ai/ui, fire tracking the same way our SDK does so your numbers reconcile with the Gravity dashboard:
  • Impression — request impUrl exactly once per served ad, when the unit first reaches 50% visibility in the viewport (IntersectionObserver with threshold: 0.5). Don’t fire on mount/render.
  • Reset per ad — if the slot receives a new ad (new impUrl), the once-per-ad rule resets for the new ad.
  • No observer available (SSR hydration edge cases, old browsers) — fall back to firing once on mount.
  • Clicks — route every click through clickUrl, never the raw url.
Gravity’s dashboard counts an impression when impUrl is requested, so a renderer following these rules will match the dashboard 1:1.

Want a custom default look for your placement?

Gravity can pin a custom spec as a placement’s default look so every ad in that slot renders it with no per-call config — designed with you, previewed against real ads on your surface before it goes live. Email support@trygravity.ai with the look you want (or a spec you’ve drafted) and we’ll set it up.
A custom default never changes tracking or attribution — only the visual DOM. Impressions and clicks fire identically regardless of which spec renders.

Security

The spec is data, not code. The runtime cannot execute anything:
  • Fixed tag whitelisttype only maps to <div>, <span>, <img>, <a>.
  • No code execution — no eval, new Function, dangerouslySetInnerHTML, or event handlers. Text is auto-escaped.
  • Sanitization — style values with javascript:/expression( are stripped; src/href must use an allowed protocol (http, https, data, mailto).
  • Bounded work — tree depth and node count are capped.
  • Scoped databind reads only the six known ad fields.

FAQ

No. New designs are delivered as renderer_spec data on the ad response. The installed runtime renders them as-is.
Yes, if the spec references theme tokens. ShadCN/Tailwind apps inherit automatically; others supply tokens via GravityThemeProvider.
Yes — a variant prop hard-pins a bundled layout and ignores the server spec.
No. With no spec at all, the SDK renders its built-in, dimension-aware look that inherits your theme. Authoring a spec is only for when you want a specific custom look.
No, as long as the spec uses theme tokens (hsl(var(--primary)), var(--radius)). Those resolve against your CSS at paint time, so the ad re-themes automatically. Only hardcoded values stay fixed.
No. A spec only changes the visual DOM. Impression and click tracking route through the same impUrl/clickUrl regardless of which spec renders, so attribution and billing are unchanged.