Popups in your app
A popup is a modal, popup or interstitial: content that interrupts the screen until the customer acts. In ContentFlow a popup is a block: a block definition marked as a popup. A marketer makes one by adding a Popup channel to a campaign and picking the ready-made Standard popup or one of your own popup blocks; your app receives it like any block and draws it. getPopup(screen) decides which popup a screen may show, after the item's frequency rule and the workspace popup limits. Campaigns made before this as an in-app popup with fixed fields still show; they are described under Legacy campaign popups.
- Make one: campaign, then Popup, then the Standard popup (fields
#title,#body,#image,#cta_label,#cta_url) or a custom popup block. The campaign's audience, schedule and frequency rule apply. - Show it: call
getPopup(screen)(or handleonBlockPopupwithpresentPopup), draw your component, and track the impression, the button tap and the close. A rule that stops on a close only works if you track the dismissal. - Legacy campaign popups (fixed title, body, image and button, made before popups became blocks) are drawn by the SDK with
presentPopup; new ones can't be made that way. - Nothing is ever shown unless you ask:
autoPresentis off by default, so upgrading the SDK adds no surprise UI. - One popup at a time. Frequency rules and the workspace limits (by default 1 per session, 3 per day, 30 minutes apart; admin-configurable) apply to both kinds.
Available in SDK 2.4.0
presentPopup, screenChanged, closePopup, the popups options, CFPopupHost and the campaign and block tracking helpers on this page are new in SDK 2.4.0, in all four SDKs. getPopup and shouldShow, described in Popup frequency and limits, are new in the same release. In Kotlin the built-in card is Compose-only: it needs the click.contentflow:compose artifact and a ContentFlowPopupHost on screen. With only click.contentflow:android, presentPopup returns no_ui_context unless you attach your own CampaignPopupPresenter with client.popups.attach(...). With SDKs before 2.4.0, the server-side frequency caps still apply, and your app decides which popup a screen shows.Make a popup: a campaign with a Popup channel
- In the campaign's Channels step, open Popup and pick the Standard popup, which every workspace has, or a block your developers marked as a popup and linked to campaigns.
- Write the block's fields in the Content step: text per language; the image is shared by every language.
- Set the audience, schedule and frequency rule, and send it for approval. A popup with no frequency rule shows at most once per day.
When the campaign goes live, the app receives the popup on its next sync as an ordinary block on the block's screen (the Standard popup's is Home screen; an admin can change it in Dynamic blocks), and getPopup(screen) returns it as kind: 'block'. Pausing or ending the campaign takes it down. Its impressions, taps and conversions count toward the campaign. See Blocks as channels.
The Standard popup's fields carry the tags #title, #body, #image, #cta_label and #cta_url. From SDK 2.4.1 the SDK draws the Standard popup in its built-in card when you set no onBlockPopup (or renderBlock) handler (see Block popups in the built-in card). With SDK 2.4.0 it reaches onBlockPopup (or your getPopup call) like any block popup, so draw those five values with your own component, as below. A custom popup block has whatever fields your developers registered.
Which one to use
| Block popup (Standard popup or your own) | Legacy in-app campaign popup | |
|---|---|---|
| Who draws it | Your app: any component you build | The SDK's built-in card, or your own through renderCampaignPopup |
| What the marketer edits | The values of the block's fields, in the campaign's Popup channel (or in the block editor) | Title, body, image, button label and link, in a campaign made before popups became blocks |
| Who tracks it | You: trackImpression, trackCtaClick, trackDismiss | The SDK, once per presentation, with the built-in card or your own renderCampaignPopup. You add events only if you want more than the impression, button tap and dismissal. |
| How you ask for it | getPopup(screen), then draw the block result | presentPopup(screen) or screenChanged(screen) with autoPresent |
| If the screen's winner is the other kind | Your code receives a campaign result and can ignore it | The SDK hands a block result to onBlockPopup; without one, from SDK 2.4.1 it draws the Standard popup (or any block with the standard fields) in the built-in card (see below); with SDK 2.4.0, or for any other block, it shows nothing (not_handled), so draw it yourself in onBlockPopup |
You can use both in one app: a screen's single popup slot goes to whichever item ranks first, and your onBlockPopup handler draws it when a block wins.
Block popups: your app draws and tracks
Ask for the screen's popup, draw your own component for a block result, and report what the customer does. Tracking is what makes frequency work. The impression feeds "most views" and "wait between shows"; the button tap and the close feed "never show again after a tap / a close". A rule set to stop on a close only stops if the app calls trackDismiss when the customer closes the popup. Without the call the popup keeps coming back.
const popup = await client.getPopup('home');
if (popup?.kind === 'block') {
showMyModal(popup.block); // your component
client.trackImpression(popup.block); // counts toward the rule
onCtaTap(() => client.trackCtaClick(popup.block));
onModalClose(() => client.trackDismiss(popup.block)); // required for "stop on close"
}import { useCF, useCFPopup } from '@contentflow/sdk/react';
function HomePopup() {
const cf = useCF();
const { popup, dismiss } = useCFPopup('home'); // impression tracked once it shows
if (popup?.kind !== 'block') return null;
// dismiss() records the dismissal and hides it. Track the button yourself.
return <MyModal block={popup.block} onCta={() => cf.trackCtaClick(popup.block)} onClose={dismiss} />;
}import { useCF, useCFPopup } from '@contentflow/sdk/react-native';
function HomePopup() {
const cf = useCF();
const { popup, dismiss } = useCFPopup('home'); // impression tracked once it shows
if (popup?.kind !== 'block') return null;
return <MyModal block={popup.block} onCta={() => cf.trackCtaClick(popup.block)} onClose={dismiss} />;
}if case .block(let block)? = await client.getPopup(screen: "home") {
present(block) // your view
_ = try await client.trackEngagement(.impression(block))
// On the button: _ = try await client.trackCtaClick(block)
// On close (required for "stop on close"):
// _ = try await client.trackDismiss(block)
}val popup = client.getPopup("home")
if (popup is Popup.Block) {
showModal(popup.block) // your composable
client.trackEngagement(CFEngagementEvent.IMPRESSION, popup.block.key, popup.block.instanceId)
// On the button: client.trackCtaClick(popup.block)
// On close (required for "stop on close"):
// client.trackDismiss(popup.block)
}final cf = ContentFlow.instance;
final popup = await cf.getPopup('home');
if (popup case CFBlockPopup(:final block)) {
await cf.trackImpression(blockKey: block.key, instanceId: block.id, screen: 'home');
showMyBlockDialog(
block,
onCta: () => cf.trackCtaClick(blockKey: block.key, instanceId: block.id),
onClose: () => cf.trackDismiss(blockKey: block.key, instanceId: block.id), // required for "stop on close"
);
}Everything about drawing, fields and slots is in Blocks & placements. For a block you draw that is not a popup, shouldShow(block) answers whether its rule allows it now.
Legacy campaign popups: the SDK draws and tracks
A legacy in-app campaign popup has a fixed schema: title, body, imageUrl, a button label and a link. The SDK draws a centered card over a dimmed backdrop: an optional image (left out if it fails to load; it never blocks the popup), the title, a scrollable body, a button only when the campaign has a label, and a close button in the top end corner. It uses the same getPopup decision as above, so frequency rules and workspace limits already apply.
There are two ways to start it. Explicitly: call presentPopup(screen) where you want a popup. On screen changes: turn on autoPresent, then report each screen with screenChanged (or mount the host component, which does it for you). After delayMs the SDK presents that screen's popup; a newer screen cancels one that has not gone up yet.
// The browser build installs the built-in popup (a shadow-root dialog) for you.
const cf = createCFClient({
publicKey: '<workspace-id>_app',
tenantId: '<workspace-id>',
popups: {
autoPresent: true, // default false: nothing shows unless you opt in
delayMs: 500, // 0..60000
onAction: (campaign, url) => { router.open(url); return true; }, // true = you handled it
onBlockPopup: (block) => showMyModal(block), // you draw and track blocks
},
});
cf.screenChanged('home'); // presents after delayMs; a newer screen cancels it
const result = await cf.presentPopup('cards'); // or ask explicitly
// result: { shown, kind: 'campaign' | 'block' | null, id?, reason? }import { CFPopupHost } from '@contentflow/sdk/react';
// Mount once inside <CFProvider>. A change of `screen` calls screenChanged.
<CFPopupHost
screen={routeName}
autoPresent
onAction={(campaign, url) => { navigate(url); return true; }}
renderBlock={(block, close) => <MyModal block={block} onClose={close} />}
/>
// Without autoPresent, ask when you want one:
// const cf = useCF(); await cf.presentPopup('cards');import { CFPopupHost } from '@contentflow/sdk/react-native'; // also for Expo
// Mount once inside <CFProvider>. Drawn on a React Native Modal.
<CFPopupHost
screen={routeName}
autoPresent
onAction={(campaign, url) => { openDeepLink(url); return true; }}
renderBlock={(block, close) => <MyModal block={block} onClose={close} />}
/>// SwiftUI: once, at the root. A change of `screen` calls screenChanged.
RootView()
.contentFlowPopups(client: client, screen: router.currentScreen)
client.popups.options = CFPopupOptions(
autoPresent: true, // off by default
delayMs: 500, // 0...60000
onBlockPopup: { block in presentMyModal(block) }, // you draw and track blocks
onAction: { campaign, url in router.handle(url) } // return true when handled
)
// Or by hand (UIKit apps: presented over the top view controller):
let result = await client.presentPopup(screen: "home")
// result.shown, result.kind, result.id, result.reasonContentFlow.client.popups.config = PopupConfig(
autoPresent = true, // default false
delayMs = 500, // 0..60000
onBlockPopup = { block -> showMyModal(block) }, // you draw and track blocks
onAction = { campaign, url -> router.handle(url) }, // true = handled
)
@Composable
fun HomeScreen() {
ContentFlowPopupHost(ContentFlow.client, screen = "home") // calls screenChanged
// ...
}
// Or on demand, without autoPresent:
val result = client.presentPopup("home") // PopupPresentation(shown, kind, id, reason)final cf = await ContentFlow.create(ContentFlowConfig(
publicKey: 'ws_abc123_app',
popups: CFPopupOptions(
autoPresent: true, // default false
delayMs: 800, // 0..60000
navigatorKey: navigatorKey,
onAction: (campaign, url) async => myRouter.open(url), // true = handled; opens nothing otherwise
onBlockPopup: (block) => showMyBlockDialog(block),
),
));
MaterialApp(
navigatorKey: navigatorKey,
navigatorObservers: [ContentFlowPopupObserver()], // route names are the screens
);
// or: ContentFlowPopupHost(screen: 'home', child: HomePage())
// or by hand: final result = await cf.presentPopup('home', context: context);What presentPopup returns
A result with shown, kind (campaign or block), id (the campaign id or the block instance id) and a reason when nothing was shown. The reason is a lower-case string with underscores in JavaScript and React, and an enum elsewhere: Swift CFPopupPresentation.Reason (.nothingToShow, .alreadyShowing), Kotlin PopupSkipReason (None, AlreadyShowing) and Flutter CFPopupNotShownReason (none, alreadyShowing). Each enum value carries the wire name in the table below.
| Reason | Meaning |
|---|---|
none | Nothing may show on this screen right now: no popup item, or every one is held back by its rule or the workspace limits. |
already_showing | A popup is on screen. Only one shows at a time, so a second call never stacks. |
not_handled | A block won the slot, no onBlockPopup handler (or renderBlock) is set, and the SDK did not draw it: it has no standard fields, blockPopups is off, or you are on SDK 2.4.0. |
features_off | Nothing to show and the workspace campaigns switch is off. |
no_ui_context | A campaign won but there is nowhere to draw it: no document, no host component mounted, or no navigator. |
What the SDK tracks
impressionwhen the card becomes visible, once per presentation.- Then exactly one of
cta_click(the button; the card closes first, then youronActionor the default open runs) ordismiss(the close button, a tap on the backdrop, Escape on the web, the back button or gesture on Android). - Both feed the frequency ledger, so "until the customer taps / closes it" rules hold with no code of yours. Consent gating is unchanged: without analytics consent nothing is sent, but the device still counts for frequency.
Block popups in the built-in card
onBlockPopup handler showed nothing and the result was not_handled. From 2.4.1 the SDK draws a block that uses the standard fields in the same card as a campaign. Set blockPopups to off to keep the 2.4.0 behaviour.When a block wins the screen's popup slot and you have not set onBlockPopup (or renderBlock on the React and React Native host), the SDK draws the block itself if it uses the standard field tags. A block uses them when it has a non-empty #title or #body. A block without them still returns not_handled, and onBlockPopup, when you set it, always wins.
| Field tag | In the card |
|---|---|
#title | The title |
#body | The body text |
#image | The image (an image field or a URL; https only) |
#cta_label | The button label |
#cta_url | The button link, checked like a campaign link |
#cta_visible | false hides the button |
- Same card: theme, layout, right-to-left, accessibility,
delayMsand one popup at a time (a campaign and a block are never on screen together). A missing image or button is left out, as for a campaign. - Result:
shown: true,kind: blockand the block's instance id asid. - Tracking: the SDK sends the block's own events, exactly once per presentation:
impression, then one ofcta_clickordismiss. They count toward the block's frequency rule and its statistics, and a closed in-app message stays closed, just as when you track a block yourself. - Your own design:
renderCampaignPopupapplies to campaigns only; a block always uses the built-in card. To draw blocks yourself, setonBlockPopup. A custom JavaScriptCFPopupPresenternow receives blocks too, so checkview.kindbefore reading campaign fields.
| SDK | Turn it off (2.4.0 behaviour) | Button handler for a block |
|---|---|---|
| JavaScript, React, React Native | popups.blockPopups: 'off' (or the blockPopups="off" host prop) | onAction(item, url), where item is the block ('campaignId' in item tells a campaign apart) |
| Swift | client.popups.options.blockPopups = .off | onBlockAction(block, url) |
| Kotlin | PopupConfig(blockPopups = BlockPopupMode.Off) | onBlockAction = { block, url -> ... } |
| Flutter | CFPopupOptions(blockPopups: CFBlockPopupMode.off) | onAction(item, url), where item is a CFBlockActionContext (item.block) |
Block button links. A block drawn in the built-in card follows the campaign link rules: only http, https and app custom schemes are passed on, and the cta_click is always tracked. The handler that gets the link differs per platform, as in the table above; in Kotlin and Swift onAction stays campaign-only. Return true when handled; otherwise the SDK does what it does for a campaign. One extra rule on Kotlin and Swift: if you set onAction but not onBlockAction, a block link with an app custom scheme (myapp://...) is not opened by the system, because your app routes its own scheme; http and https links still open. Set onBlockAction to route block deep links.
Options and theme
Options live in popups in the client configuration (Swift: client.popups.options; Kotlin: client.popups.config). In React, the same fields are props on CFPopupHost and override the configuration.
| Option | Default | What it does |
|---|---|---|
autoPresent | off | Present on screenChanged. Leave it off to call presentPopup yourself. |
delayMs | 0 | Wait before an automatic presentation. Clamped to 0 to 60000. A newer screen cancels a pending one. |
theme | neutral, light and dark | Background, text colour, accent (the button), button text colour, corner radius and an optional font. Anything unset follows the system appearance. |
onAction(campaign, url) | SDK opens the link | Handle the button yourself (deep links). Return true when handled; false or no handler lets the SDK open it. See Links. |
renderCampaignPopup | built-in card | Draw the campaign popup yourself (renderCampaign on the React host; in Kotlin a renderCampaignPopup parameter of the Compose ContentFlowPopupHost, not a PopupConfig field; a View-based Kotlin app attaches a CampaignPopupPresenter with client.popups.attach(...) instead). You get the campaign and two actions, button tapped and dismissed; call them when the customer acts and the SDK keeps the tracking. In Flutter the builder receives a session with tapCta() and dismiss(); in Swift a context with performAction() and dismiss(); in Kotlin a request with onShown(), onCtaClick() and onDismiss(). |
onBlockPopup(block) | none | Receives a block that won the slot. You draw it and track it as in Block popups. Without it a block with the standard fields is drawn in the built-in card (SDK 2.4.1 and later), and any other block shows nothing (not_handled). |
blockPopups | built-in | SDK 2.4.1 and later. Off keeps the 2.4.0 behaviour: the SDK never draws a block. See Block popups in the built-in card for the name on each platform. |
If you need to take a popup down from code, every SDK offers closePopup() (JavaScript also has isPopupShowing() and setPopupPresenter() to plug in your own surface). closePopup() closes the popup silently: no dismiss event is recorded and the one-popup-at-a-time slot is freed. In Flutter it does not cancel an automatic presentation that is still waiting for its delayMs.
Only the customer closing a popup counts as a dismissal: the close button, a tap on the backdrop or outside the card, and back or Escape. A close made by your app or the navigator (for example Flutter popUntil or removeRoute, or a host leaving the screen) records no dismiss. Leaving a screen's host unmounted releases the popup the same way.
Links and deep links
The button's link is whatever the marketer typed, so the SDK checks it before opening anything. It opens only http, https and your app's own custom schemes. javascript:, data:, file:, vbscript:, blob: and about: (any case, spaces trimmed) are never opened. Kotlin on Android also refuses intent: and content:. A refused link does nothing, but the cta_click is still tracked and the card still closes.
- Deep links: set
onActionand route the URL yourself. It is called only with an allowed URL. - Flutter has no URL launcher dependency: with no
onAction, or when it returns false, the button tracks and closes and nothing opens. Addurl_launcherto your app and call it fromonActionto open web links. - watchOS has no system URL opener in the SDK: set
onActionto handle the button. - Campaign text is drawn as plain text, never as HTML or markup, and the image loads over
httpsonly.
For where block and campaign links are allowed to point, and how a push link differs, see Buttons, links & deep links.
Right-to-left and accessibility
- Direction: it comes from the requested or client locale: the locale you pass to
presentPopup, else the client's locale. Swift checks the campaign's ownlocalebefore the client's; Kotlin falls back to English, laid out left to right, when the campaign has no content in that locale.ar,he(and its old codeiw),fa,ur,ps,sd,yiandckblay the card out right to left (Kotlin alsoji; Flutter does not recogniseiw). Otherwise the platform's layout direction applies. Text aligns to the start; the close button sits in the top end corner, which is the left in Arabic. Pass the locale topresentPopupto choose both the content language and the direction (JavaScript, Swift and Flutter; Kotlin'spresentPopuptakes no locale and uses the client's). - Close label: "Close", or "إغلاق" for Arabic. The close target is at least 44 pt (iOS) or 48 dp (Android, Flutter).
- Screen readers: the web card is a
role="dialog"witharia-modal="true"named by its title, moves focus into the dialog, traps it there and restores it on close. On iOS the card is modal to VoiceOver with focus on the title; Android and Flutter expose dialog semantics with the title as the label. - Text size and motion: text follows Dynamic Type and system font scaling, a long body scrolls, safe areas and notches are respected, and the popup does not animate (or only fades) when the system asks for reduced motion.
- Dark mode: the default colours follow the system appearance; set
themeonly to apply your brand.
renderCampaignPopup, renderBlock or a block popup, the semantics are yours. Mark the surface as a modal dialog, label it with the title, give the close control a localized name, and mirror the layout for right-to-left.How frequency and popup limits apply
Both kinds pass through getPopup, so the marketer's frequency rule on the item and the workspace Popup limits (Settings → Workspace → Popup limits, admin-only) are checked before anything is drawn. The defaults are 1 popup per app session, 3 per day and 30 minutes between two popups, and an admin can change them or exempt a regulatory notice (both need the Pro or Enterprise plan; see the plan rules). When a rule or a limit holds an item back, presentPopup reports none and nothing shows; no extra code is needed. Ask frequencyReason to see why.
Counting depends on tracking: the impression always counts, and a rule that stops after a tap or a close counts those events. For a campaign popup the SDK sends them. For a block popup you send them, which is why the dismissal call above is not optional. The full rules, the server side and the diagnostics are in Popup frequency and limits.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
| Nothing ever appears | autoPresent is off by default. Call presentPopup(screen), or turn it on and report screens with screenChanged or the host component. Then check the result's reason. |
reason is none | No campaign is live for a placement named like the screen, or its rule or a workspace limit holds it. Check frequencyReason; the campaign's placement must match the screen name you pass. |
reason is not_handled | A block won the slot and the SDK could not draw it. Give the block a #title or #body so the built-in card draws it (SDK 2.4.1 and later, blockPopups not off), or set onBlockPopup (or renderBlock) and draw it yourself. |
reason is no_ui_context | The host is not mounted (React, React Native, Compose, SwiftUI), no navigator is available (Flutter: pass context, set navigatorKey, or add the observer) or there is no document. |
reason is already_showing | Working as designed: one popup at a time. Wait for the first to close. |
| A block popup that should stop after a close keeps returning | The app never called trackDismiss, so the rule never saw the close. Track it in the close handler. |
| The button closes the card but opens nothing | The link failed the scheme check, onAction returned true without navigating, or you are on Flutter or watchOS without an onAction. For a block in Kotlin or Swift: you set onAction but not onBlockAction and the link uses your app scheme (see Block button links). |
| The popup shows too early or after the next screen already loaded | Raise delayMs; a newer screenChanged cancels a pending popup, so report every screen change, including the one you leave. |
Next
- Popup frequency & limits: the rules, the workspace limits and "why didn't it show".
- Campaigns & journeys: create the in-app campaign this page draws.