Skip to content

Rendering Ads

The <EloAd> component renders an ad in the format the server chose for it, the card or the strip, and handles all tracking automatically.

import { EloAd } from '@elo-ads/web-sdk'
<EloAd sdk={sdk} ad={ad} className="my-ad" style={{ maxWidth: 320 }} />
PropTypeRequiredDescription
sdkEloSDKYesThe initialized SDK instance
adAdCreativeYesThe ad creative returned by fetchAds()
classNamestringNoAdditional CSS class name
styleReact.CSSPropertiesNoInline styles
sponsoredLabelstringNoThe ad disclosure, under the title on the card and leading the title line on the strip; defaults to "Ad". Pass a localized string for non-English UIs

The component outputs a container, <div class="elo-ad"> with elo-ad-card or elo-ad-strip for the format, holding:

  • The image (if ad.image_url is present) with <img class="elo-ad-image"> — hidden automatically if the image fails to load, for that ad only, so the same creative gets another chance the next time it serves
  • The title in <div class="elo-ad-title"> and the “Ad” disclosure in <span class="elo-ad-sponsored">. On the card the disclosure sits on its own line under the title; on the strip it leads the title line, which reads Ad • Headline
  • The description in <div class="elo-ad-description">: up to three lines on the card and one line on the strip, ending in an ellipsis when the copy is longer, as on iOS and Android. A creative without a description renders no description line
  • A CTA button in <span class="elo-ad-cta">, only when the ad carries a cta_text and the publisher’s cta_enabled is not false. Where the whole ad is the link it is a span inside that link; where the button is the link it sits inside its own, <a class="elo-ad-cta-target">
  • A trailing chevron in <svg class="elo-ad-chevron">, only when it does not — it signals that the whole ad is the link

Clicks open ad.click_url in a new tab (target="_blank").

The server picks the format from the maxHeight you passed to fetchAds(), and the ad carries it as ad.presentation.format. You never choose it yourself; see Ad Formats.

  • Card (card, and the default for an ad without presentation): set like a feed ad, as on iOS and Android, for a slot in the chat transcript. A header row holds a 36px image beside the title with the “Ad” disclosure under it, the description follows at full width, and the CTA button sits on a row of its own at the trailing edge, or a chevron closes the header row. The card takes the height its copy needs. The server returns it for an unbounded slot or one at least 192px tall.
  • Strip (strip): one 72px row with a 40px image and a one-line headline and description, for a slot beside the composer. The server returns it for a slot from 72px to 191px tall.

The strip is drawn no taller than the slot it was chosen for; the card grows with its copy inside a slot of at least 192px.

  • Card: a single link wraps the whole card, image, text, and CTA label included, so every part of it clicks through and there are no dead zones between elements. With ad.cta_text, a filled capsule carries the label as decoration inside the card link; without it, a chevron signals that the card is tappable.
  • Strip with a CTA: the button is the strip’s only link, grown to a 44px target, and the rest of the strip is inert. The strip sits next to the composer, where a click that misses the button should open nothing.
  • Strip without a CTA: the whole strip is the link.

Demand sources set the label (Install, Download now, …) and many send none, so expect both. presentation.cta_enabled: false hides the button whatever the creative sends.

A CTA pill can also carry an attention treatment Elo selects: an arrow after the label, or a short pop or shimmer once the ad has been at least half visible for one second, repeated every few seconds while it stays in view. There is no motion when the reader prefers reduced motion. There is nothing to configure in code.

The component fires tracking events without any additional code from you.

Fires a POST to ad.render_url when the component mounts. This tells the ad server the ad was injected into the DOM. Deduped per ad opportunity - mounting the same ad twice only fires once, while a new response from fetchAds() tracks independently even if it returns the same creative.

Fires a POST to ad.impression_url when:

  • The ad is at least 50% visible in the viewport (via IntersectionObserver)
  • The user has dwelt for at least 1 second

Also deduped per ad opportunity. This is the billable event - it signals the ad was actually seen.

Every click inside the card is reported to POST /v1/sdk/events: the region under the pointer (thumbnail, headline, description, CTA, chevron, or the card’s own padding), the position as a fraction of the card’s size, the card’s size, whether the click opened the destination, the time since the impression, and the click’s index within the showing. Coordinates are relative to the card, never the page or the screen. A click on an inert part of the strip is reported as not opened, which is what makes a missed-CTA rate measurable.

This is analytics, not billing, and it’s what makes a tap heatmap and a missed-call-to-action rate measurable. Nothing about how the card behaves changes: the click still navigates to ad.click_url in a new tab, and the ad server still counts it there. A middle click on the ad’s link opens it in a new tab too, and is reported the same way. A click within one second of opening an ad opens nothing, so a double click opens one tab and counts one click; that click is reported as not opened. Unlike renders and impressions, taps aren’t deduped, and every one is reported. A tap that fails on the network is retried twice; one the ad server rejects is not.

Some demand sources ask for the notice that an ad was displayed or clicked to come from the reader’s browser rather than from Elo’s servers, so their systems see the request as the reader’s. When an ad carries them they arrive as ad.trackers, and the component sends each one at the moment it names — display, viewable impression, or click, for a pointer click, a middle click and a keyboard activation alike. Each URL belongs to the demand source and is sent as given, with nothing read back. Sent once per showing, never retried, and most ads carry none. They go to the demand source’s own domain, so a Content Security Policy that limits connect-src to Elo blocks them, and so does an ad blocker that lists the source; the browser drops them silently either way.

If <EloAd> doesn’t fit your design, render the creative yourself and report it with sdk.trackRender(ad) and sdk.trackImpression(ad), plus sdk.trackClick(ad) on click so any demand-source notices go out, and sdk.trackTap(ad, tap) if you want your layout in the tap analytics. Don’t hit the tracking URLs directly — the per-opportunity limit lives behind these methods, and a direct request skips the identity headers the ad server needs.

import { useEffect } from 'react'
function MyAdCard({ sdk, ad }) {
useEffect(() => {
sdk.trackRender(ad)
}, [sdk, ad])
// ...then call sdk.trackImpression(ad) once your own viewability check
// (≥50% visible for 1 continuous second) passes.
return (
<a href={ad.click_url} target="_blank" rel="noopener" onClick={() => sdk.trackClick(ad)}>
<span>Ad • {ad.title}</span>
</a>
)
}

Both are safe to call on every render pass: the SDK sends at most one render and one impression per ad opportunity, and each resolves true only when the ad server confirmed the ping. sdk.hasTrackedImpression(ad) reports whether an opportunity has already impressed, so you can skip arming your dwell timer.

sdk.trackTap(ad, tap) is optional and works differently: it isn’t deduped, so call it once per click rather than per render pass, and measure the position against your own card’s box. Set opened to false for a click that landed on a part of your layout that doesn’t open the ad — that’s what makes a dead tap distinguishable from one that worked. See the API Reference for the shape.

That limit is held per SDK instance, so create the SDK once for the page — as a module-level constant or a stable ref — rather than rebuilding it as your component tree re-renders. A second instance starts with an empty record and would report the same showing again.

Two rules the SDK can’t enforce for you: the card must carry a visible “Ad” disclosure, and it must link to ad.click_url — clicks are counted by the ad server at that URL, not by an SDK ping. Honour ad.presentation.cta_enabled too: when it is false, draw no CTA button even if the ad carries cta_text.

fetchAds() returns ad: null when no ad is available (no fill). Always handle this case:

function ChatAdSlot({ sdk, ad }) {
if (!ad) return null
return <EloAd sdk={sdk} ad={ad} />
}

Call fetchAds() after each assistant response, passing the full conversation so far. The backend extracts keywords from the chat context to match relevant ads.

Avoid calling on every keystroke or on user messages alone - the best signal comes from complete exchanges.

async function onAssistantReply(allMessages) {
const response = await sdk.fetchAds({ messages: allMessages })
if (response.ad) {
setAd(response.ad)
}
}

For a slot with limited height, such as above the composer, pass maxHeight in CSS pixels and the server returns the format that fits. Leave it unset for a slot in the transcript. See Ad Formats.

const response = await sdk.fetchAds({ messages: allMessages, maxHeight: 72 })

The component uses these CSS class names that you can target:

.elo-ad {
/* outer container, with .elo-ad-card or .elo-ad-strip for its format */
}
.elo-ad-sponsored {
/* "Ad" disclosure label */
}
.elo-ad-image {
/* ad image */
}
.elo-ad-title {
/* ad title text */
}
.elo-ad-description {
/* ad description text */
}
.elo-ad-cta {
/* CTA button, when the ad carries one */
}
.elo-ad-cta-target {
/* on the strip, the link around the CTA button */
}
.elo-ad-chevron {
/* trailing chevron, when the ad carries no CTA */
}

The hairline border is drawn inside the container as an inset box-shadow, so it adds no height to the format’s. To restyle or remove it, set box-shadow on .elo-ad rather than border.

You can also pass className and style props for quick customization:

<EloAd
sdk={sdk}
ad={ad}
className="dark-theme-ad"
style={{ borderRadius: 8, overflow: 'hidden' }}
/>