Home / Guides / Popups in your app
Product guide

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.

In short
  • 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 handle onBlockPopup with presentPopup), 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: autoPresent is 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.
Availability

Available in SDK 2.4.0

SDK 2.4.0 and laterpresentPopup, 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

Make a popup: a campaign with a Popup channel

  1. 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.
  2. Write the block's fields in the Content step: text per language; the image is shared by every language.
  3. 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.

Choose

Which one to use

Block popup (Standard popup or your own)Legacy in-app campaign popup
Who draws itYour app: any component you buildThe SDK's built-in card, or your own through renderCampaignPopup
What the marketer editsThe 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 itYou: trackImpression, trackCtaClick, trackDismissThe 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 itgetPopup(screen), then draw the block resultpresentPopup(screen) or screenChanged(screen) with autoPresent
If the screen's winner is the other kindYour code receives a campaign result and can ignore itThe 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.

You draw

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.

The SDK draws

Legacy campaign popups: the SDK draws and tracks

LegacyNew popups are made as blocks (see Make a popup); the campaign editor no longer offers to turn a banner or in-app campaign into a popup. A campaign saved as a popup before keeps showing as one, as described here.

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.reason
ContentFlow.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.

ReasonMeaning
noneNothing may show on this screen right now: no popup item, or every one is held back by its rule or the workspace limits.
already_showingA popup is on screen. Only one shows at a time, so a second call never stacks.
not_handledA 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_offNothing to show and the workspace campaigns switch is off.
no_ui_contextA campaign won but there is nowhere to draw it: no document, no host component mounted, or no navigator.

What the SDK tracks

  • impression when the card becomes visible, once per presentation.
  • Then exactly one of cta_click (the button; the card closes first, then your onAction or the default open runs) or dismiss (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.
The SDK draws

Block popups in the built-in card

New in SDK 2.4.1In SDK 2.4.0 a block popup with no 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 tagIn the card
#titleThe title
#bodyThe body text
#imageThe image (an image field or a URL; https only)
#cta_labelThe button label
#cta_urlThe button link, checked like a campaign link
#cta_visiblefalse hides the button
  • Same card: theme, layout, right-to-left, accessibility, delayMs and 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: block and the block's instance id as id.
  • Tracking: the SDK sends the block's own events, exactly once per presentation: impression, then one of cta_click or dismiss. 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: renderCampaignPopup applies to campaigns only; a block always uses the built-in card. To draw blocks yourself, set onBlockPopup. A custom JavaScript CFPopupPresenter now receives blocks too, so check view.kind before reading campaign fields.
SDKTurn it off (2.4.0 behaviour)Button handler for a block
JavaScript, React, React Nativepopups.blockPopups: 'off' (or the blockPopups="off" host prop)onAction(item, url), where item is the block ('campaignId' in item tells a campaign apart)
Swiftclient.popups.options.blockPopups = .offonBlockAction(block, url)
KotlinPopupConfig(blockPopups = BlockPopupMode.Off)onBlockAction = { block, url -> ... }
FlutterCFPopupOptions(blockPopups: CFBlockPopupMode.off)onAction(item, url), where item is a CFBlockActionContext (item.block)
Options

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.

OptionDefaultWhat it does
autoPresentoffPresent on screenChanged. Leave it off to call presentPopup yourself.
delayMs0Wait before an automatic presentation. Clamped to 0 to 60000. A newer screen cancels a pending one.
themeneutral, light and darkBackground, 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 linkHandle the button yourself (deep links). Return true when handled; false or no handler lets the SDK open it. See Links.
renderCampaignPopupbuilt-in cardDraw 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)noneReceives 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).
blockPopupsbuilt-inSDK 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.

Languages and accessibility

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 own locale before 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 code iw), fa, ur, ps, sd, yi and ckb lay the card out right to left (Kotlin also ji; Flutter does not recognise iw). 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 to presentPopup to choose both the content language and the direction (JavaScript, Swift and Flutter; Kotlin's presentPopup takes 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" with aria-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 theme only to apply your brand.
If you draw it yourselfWith 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.
Frequency

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.

Troubleshooting

When it doesn't work

What you seeWhy, and the fix
Nothing ever appearsautoPresent 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 noneNo 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_handledA 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_contextThe 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_showingWorking as designed: one popup at a time. Wait for the first to close.
A block popup that should stop after a close keeps returningThe app never called trackDismiss, so the rule never saw the close. Track it in the close handler.
The button closes the card but opens nothingThe 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 loadedRaise delayMs; a newer screenChanged cancels a pending popup, so report every screen change, including the one you leave.

Next