npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pnlight/sdk-react-native

v0.17.0

Published

React Native wrapper for PNLight iOS binary SDK

Readme

PNLight SDK - React Native Package

npm package

A React Native wrapper for the PNLight iOS SDK.

Installation

Install the package in your React Native or Expo app:

npm install @pnlight/sdk-react-native

or:

yarn add @pnlight/sdk-react-native

Then install iOS pods:

cd ios
pod install

Requirements

  • iOS 15.0+
  • React Native app with CocoaPods
  • Swift 5.7+

This package is iOS-only. Android is not supported.


iOS Setup

Native dependencies

The package's podspec installs DivKit, its extension module, and Lottie, and links the Apple frameworks PNLight needs. No DivKit source, Lottie registration, navigation controller, haptic handler, or other Remote UI setup is required in the host app. After installing the package, run:

cd ios
pod install

The optional backend-controlled Home gesture deferral is an exception: for an embedded RemoteUiView, the app must configure its iOS screen controller once as described in Defer the Home gesture. Without that setup, deferHomeGesture: true has no effect. No JavaScript setup is needed.

For apps that request IDFA tracking, add NSUserTrackingUsageDescription to Info.plist:

<key>NSUserTrackingUsageDescription</key>
<string>This app uses device tracking to provide analytics and improve user experience.</string>

Usage

Initialization

Initialize PNLight before using analytics, attribution, or Remote UI:

import { initialize } from "@pnlight/sdk-react-native";

await initialize("your-api-key");

Event Logging

import { logEvent } from "@pnlight/sdk-react-native";

await logEvent("purchase_completed", {
  product_id: "premium_subscription",
  amount: 9.99,
  currency: "USD",
});

Attribution

Send attribution data from external providers before requesting UI config.

import { addAttribution } from "@pnlight/sdk-react-native";

const success = await addAttribution(
  "appsFlyer",
  { af_status: "Non-organic" },
  "your-appsflyer-id",
);

AppsFlyer Integration Example

PNLight does not depend on the AppsFlyer initialization order — it only needs the conversion data, delivered via addAttribution. Make sure the conversion ("attribution success") callback is not processed before PNLight is initialized: if it can fire earlier, store the conversion data in memory and call addAttribution once initialize completes.

AppsFlyer requires the ATT prompt to complete before it starts. If PNLight is initialized before ATT authorization, call updateIdfa() after the prompt completes so PNLight receives the granted IDFA.

import appsFlyer from "react-native-appsflyer";
import { requestTrackingPermission } from "react-native-tracking-transparency";
import {
  addAttribution,
  initialize,
  updateIdfa,
} from "@pnlight/sdk-react-native";

const getAppsFlyerUID = () =>
  new Promise<string | null>((resolve) => {
    appsFlyer.getAppsFlyerUID((error, uid) => {
      if (error) {
        console.error("AppsFlyer UID error", error);
        resolve(null);
        return;
      }

      resolve(uid);
    });
  });

export async function startSdks() {
  await initialize("your-api-key");

  // Request ATT, then pass the granted IDFA to PNLight.
  await requestTrackingPermission();
  await updateIdfa();

  // Start AppsFlyer after ATT completes (an AppsFlyer requirement).
  appsFlyer.onInstallConversionData(async (conversionData) => {
    const appsFlyerId = await getAppsFlyerUID();

    await addAttribution(
      "appsFlyer",
      conversionData?.data ?? conversionData,
      appsFlyerId,
    );
  });

  appsFlyer.initSdk(
    {
      devKey: "your-appsflyer-dev-key",
      appId: "your-ios-app-id",
      isDebug: false,
      onInstallConversionDataListener: true,
    },
    (result) => {
      console.log("AppsFlyer initialized", result);
    },
    (error) => {
      console.error("AppsFlyer init error", error);
    },
  );
}

Supported providers:

  • appsFlyer
  • firebase
  • facebook

User Identity

import { getUserId } from "@pnlight/sdk-react-native";

const userId = await getUserId();

In-App Purchases

PNLight wraps StoreKit 2 for fetching products (price, offers, trial info), purchasing, restoring, and checking entitlements. The product ids are configured on the backend — fetchProducts resolves them against the App Store.

import {
  fetchProducts,
  purchase,
  restorePurchases,
  isPremium,
  isEligibleForTrial,
} from "@pnlight/sdk-react-native";

// Load the configured products with their App Store price/offer info.
const products = await fetchProducts();
for (const product of products) {
  console.log(`${product.displayName}: ${product.displayPrice}`);

  const offer = product.subscription?.introductoryOffer;
  if (offer && product.subscription?.isEligibleForIntroOffer) {
    // e.g. pay-as-you-go: "$0.99/month for 6 months"
    console.log(
      `Offer: ${offer.displayPrice} (${offer.paymentMode}, ` +
        `${offer.periodCount} × ${offer.period.value} ${offer.period.unit})`,
    );
  }
}

// Purchase.
const result = await purchase("your.product.id");
if (result === "success") {
  // Unlock content.
}

// Entitlement checks (local StoreKit entitlements, work offline).
const premium = await isPremium();
const eligible = await isEligibleForTrial("your.product.id");

// Restore previous purchases.
await restorePurchases();

RemoteUiView - Server-driven UI

RemoteUiView fetches and renders a server-driven layout from PNLight for a given placement. It calls getUIConfig(placement) internally, renders the native view, and emits action events to JavaScript.

When using external attribution providers such as AppsFlyer, send attribution as early as possible (see the AppsFlyer example above). getUIConfig waits for attribution data internally when attributionRequired is true (the default), so no manual delay is needed.

import React from "react";
import { StyleSheet, View } from "react-native";
import { RemoteUiView, type ActionEvent } from "@pnlight/sdk-react-native";

export function PaywallScreen() {
  const handleAction = (event: ActionEvent) => {
    if (event.logId === "open_terms") {
      // Handle app-specific custom actions.
    }
  };

  const handlePurchased = ({ productId }: { productId: string }) => {
    console.log(`Purchased ${productId}`);
  };

  const handleClosed = () => {
    // Close the screen, e.g. navigation.goBack()
  };

  return (
    <View style={styles.root}>
      <RemoteUiView
        placement="paywall"
        onAction={handleAction}
        onPurchased={handlePurchased}
        onClosed={handleClosed}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
  },
});

Defer the Home gesture

For schema v8 Remote UI, the backend can set deferHomeGesture on a placement. When enabled, iOS requires a second swipe from the bottom edge to leave the app. RemoteUiView reads the setting from getUIConfig; there is no React prop to set it on the high-level view.

UIKit reads this preference from a view controller, so the iOS controller that hosts the React Native screen must opt in once. In the app's ios/ project, find the UIViewController that owns the React Native root view and add this override (or use this subclass as that screen's controller):

import UIKit
import PNLightSDK_ReactNative

final class RemoteUIHostViewController: UIViewController {
  override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
    PNLightHomeGestureDeferral.preferredEdges(for: self)
  }
}

Use the subclass in place of the existing screen controller; merely adding the Swift file does not activate it. If a custom navigation, tab, or other container controller owns that screen, it must return the visible child from childForScreenEdgesDeferringSystemGestures. The SDK then updates UIKit's preference when Remote UI appears, changes, or disappears. There is no JS prop or function that can replace this one-time native host setup for an embedded view. SDK-owned full-screen flow modals handle their own gesture preference.

System purchase action

Remote UI can start a StoreKit purchase inside PNLight with a DivKit typed custom action, without a purchase handler in JavaScript:

{
  "log_id": "purchase_button",
  "typed": { "type": "custom" },
  "payload": {
    "id": "pnlight.purchase",
    "params": {
      "product_id": "{{product_1}}",
      "on_success": [{
        "log_id": "purchase_success",
        "url": "pnlight://navigation/replace?route=protected"
      }],
      "on_fail": [{
        "log_id": "purchase_failed",
        "url": "pnlight://dialog/show?id=purchase_error"
      }],
      "on_cancel": [{
        "log_id": "purchase_cancelled",
        "typed": {
          "type": "set_variable",
          "variable_name": "is_purchasing",
          "value": { "type": "boolean", "value": false }
        }
      }]
    }
  }
}

payload.id must be pnlight.purchase, and params.product_id is required. params.on_success is optional in schema v3. Schema v4 additionally supports the optional params.on_fail and params.on_cancel lists. Each accepts ordinary DivKit action dictionaries. PNLight runs the list matching the outcome with the original DivKit action context, so navigation, set_variable, haptics, dialogs, and URL actions retain their normal behavior. An outcome with no list has no follow-up.

on_success runs for success, on_fail runs when the purchase fails with an error, and on_cancel runs when the user dismisses the StoreKit sheet. A pending StoreKit purchase runs no follow-up and leaves the current UI in place, because the transaction may still be approved later. Repeated purchase taps are ignored while one SDK-owned purchase is in progress.

The view's optional onPurchased callback receives { productId } after this SDK-owned purchase is verified successfully. It is not emitted for manual purchase(productId) calls or purchases started by another Remote UI view.

The purchase action and on_success are schema v3 features. on_fail and on_cancel require "schemaVersion": 4; schema v3 documents retain their original success-only behavior. Schema v1/v2 documents do not execute pnlight.purchase.

System close action

Remote UI can ask the host to close it with a plain URL action:

{
  "log_id": "close_button",
  "url": "pnlight://close"
}

PNLight consumes the URL and calls the view's onClosed callback. The native view does not dismiss anything itself: the app owns the screen, so navigate back from onClosed. The action works from any route of a flow, including presented sheets, and from dialog buttons and purchase follow-ups.

Right after onClosed, PNLight delivers the custom action myapp://close with logId close_button through onAction. This is the action documents used before pnlight://close existed, so an app that still closes from onAction keeps working without changes. Handle the close in one of the two callbacks, not both.

pnlight://close requires "schemaVersion": 5. In older schemas the URL is not consumed and reaches onAction as an ordinary custom action.

System restore action

Remote UI can restore the user's purchases with a typed DivKit custom action:

{
  "log_id": "restore_purchases",
  "typed": { "type": "custom" },
  "payload": {
    "id": "pnlight.restore",
    "params": {
      "on_success": [
        { "log_id": "restored", "url": "pnlight://close" }
      ],
      "on_fail": [
        { "log_id": "restore_failed", "url": "pnlight://dialog/show?id=restore_error" }
      ]
    }
  }
}

PNLight syncs the user's App Store purchases, the same work as restorePurchases(), then runs on_success, or on_fail when the sync fails or the user cancels the App Store sign-in. params and both lists are optional. Follow-ups are ordinary DivKit actions and run in the context of the card that started the restore. While a purchase or a restore is in flight, another one is ignored.

pnlight.restore requires "schemaVersion": 6; older schemas ignore it.

Products

Product state belongs to one document. All flow routes, including preloaded and modal routes, share the selected product and store updates. Applying a new document starts a fresh selection; an absent or empty products list removes the previous document's product variables. Schemas 1–5 do not publish them.

A paywall lists the products it sells at the document root, a flow's root included:

"products": [
  { "id": "{{product_1}}", "selected": true,
    "fallback": { "price": "$39.99", "period": "year", "has_trial": true, "trial_count": 3, "trial_unit": "day" } },
  "{{product_2}}"
]

An entry is a product id, or an object with an id, an optional selected flag and an optional fallback. PNLight publishes three variables before the first render and keeps them current:

| Variable | Type | Value | | --- | --- | --- | | products | array | One dictionary per product, in the document's order | | selected_product | string | The selected product id: the entry marked selected, else the first | | selected | dict | The dictionary of the selected product |

Every dictionary always has every key:

| Key | Type | Value | | --- | --- | --- | | id | string | Product id | | name | string | What the document calls the plan; only ever the fallback's | | price | string | Localized price, e.g. "$39.99" | | period, period_count | string, integer | Renewal period: day, week, month or year, and how many | | price_per_month, price_per_week | string | The price spread over the period, written like price | | has_trial | boolean | The product has a free trial and this user can still get it | | trial_count, trial_unit | integer, string | Trial length, e.g. 3 and day | | intro_price | string | Price of a paid introductory offer, else empty | | is_selected | boolean | Whether this is the selected product | | is_loaded | boolean | Whether the values came from the store |

Values start from fallback (empty strings, zeroes and false where it says nothing) and are replaced by the store's as soon as they are known; a product the store does not have keeps its fallback.

Draw the plans with DivKit's item_builder, which repeats one design per product and names the current one. Tapping a plan selects it with an ordinary set_variable; is_selected and selected follow.

{
  "type": "container",
  "item_builder": {
    "data": "@{products}",
    "data_element_name": "product",
    "prototypes": [{
      "div": {
        "type": "container",
        "border": {
          "corner_radius": 16,
          "stroke": { "color": "@{product.getBoolean('is_selected') ? '#FF4E6BFF' : '#33FFFFFF'}" }
        },
        "items": [
          { "type": "text", "text": "@{product.getString('price')}/@{product.getString('period')}" },
          {
            "type": "text",
            "text": "@{product.getInteger('trial_count')}-day free trial",
            "visibility": "@{product.getBoolean('has_trial') ? 'visible' : 'gone'}"
          }
        ],
        "actions": [{
          "log_id": "select_plan",
          "typed": {
            "type": "set_variable",
            "variable_name": "selected_product",
            "value": { "type": "string", "value": "@{product.getString('id')}" }
          }
        }]
      }
    }]
  }
}

Anything outside the list reads the selection, "@{selected.getString('price')}/@{selected.getString('period')}", and the buy button purchases it: from schema v6 pnlight.purchase resolves an expression in product_id, so write "product_id": "@{selected_product}".

A document lists at most 16 products, each once. An invalid products array fails the document. products requires "schemaVersion": 6; older schemas ignore it.

Remote UI schema version

PNLight versions the Remote UI envelope independently from the npm package. Schema v7 adds root-level document-file image prefetching so listed pictures are decoded before the first layout. Schemas v1–v6 keep their previous loading and rendering behavior. Schema v6 adds the pnlight.restore action, the root products array with its products, selected_product and selected variables, and expressions in the purchase action's product_id. Schema v5 adds the pnlight://close action with its onClosed event. Schema v4 adds on_fail and on_cancel outcome hooks to pnlight.purchase. Schema v3 adds the SDK-owned pnlight.purchase action with its onPurchased event, and the pnlight.progress_bar and pnlight.animated_number native components. Schema v2 remains supported for existing documents and continues to provide safe-area handling, scaling variables, flows, haptics, and dialogs. Declare "schemaVersion": 2 at the document root to enable PNLight safe-area handling, linear-scaling variables, server-driven flows, native haptics, and native dialogs:

{
  "schemaVersion": 2,
  "card": {}
}

A missing schemaVersion (or an explicit value of 1) is legacy v1. Existing backend configs therefore keep their previous edge-to-edge DivKit behavior when an app upgrades the package. They do not receive v2 scaling variables or opt into PNLight-owned navigation, haptic, and dialog action handling. New configs that use any feature below should declare "schemaVersion": 2.

Safe areas

PNLight keeps Remote UI content clear of the Dynamic Island, status bar, home indicator, and landscape sensor housing without host configuration in schema v2. V2 documents default to inset mode on all four edges.

| Mode | Behavior | | --- | --- | | inset | Default. Places the complete DivKit document inside the selected safe-area edges. | | content | Keeps the document edge-to-edge and exposes the selected insets through DivKit variables. | | edge_to_edge | Keeps the document edge-to-edge and resolves the safe-area variables to zero. |

Use content for a full-bleed background whose inner content respects system UI:

{
  "schemaVersion": 2,
  "safe_area": {
    "mode": "content",
    "edges": ["top", "bottom"]
  },
  "card": {
    "log_id": "full_bleed_offer",
    "states": [
      {
        "state_id": 0,
        "div": {
          "type": "container",
          "width": { "type": "match_parent" },
          "height": { "type": "match_parent" },
          "paddings": {
            "top": "@{safe_area_top + 24}",
            "bottom": "@{safe_area_bottom + 24}",
            "left": 24,
            "right": 24
          },
          "items": []
        }
      }
    ]
  }
}

The variables are safe_area_top, safe_area_bottom, safe_area_left, and safe_area_right. They update automatically after rotation, resizing, and sheet presentation. A flow may declare a default safe_area beside routes; an individual route may override it beside that route's divkit document.

Linear scaling variables

Remote UI JSON may declare a logical design viewport:

{
  "schemaVersion": 2,
  "referenceSize": { "width": 390, "height": 844 },
  "card": {}
}

PNLight exposes scaleX = availableWidth / referenceSize.width and scaleY = availableHeight / referenceSize.height as live numeric DivKit variables. Use them directly in expressions, for example "left": "@{24 * scaleX}". They update for rotation, host-view resize, and native sheet-size changes. In inset safe-area mode, the available viewport is measured after the selected system insets are removed.

The ratios are raw, not automatically clamped, and PNLight does not scale the layout unless markup references them. For a uniform mobile design scale, prefer scaleX and apply your chosen bounds in markup. Never multiply system safe-area or keyboard insets by these values. Without referenceSize, both variables equal 1 in schema v2.

Flows may define referenceSize beside routes; individual routes may override it beside divkit or inside their complete divkit document. No React Native configuration is required.

Server-driven native flows

RemoteUiView accepts a PNLight flow envelope without changing the React Native API. The initial route is embedded in the React Native view; the shared iOS renderer owns subsequent native navigation and modal presentation.

{
  "schemaVersion": 2,
  "type": "flow",
  "initial_route": "welcome",
  "routes": {
    "welcome": {
      "divkit": {
        "templates": {},
        "card": {
          "log_id": "welcome",
          "states": [
            {
              "state_id": 0,
              "div": {
                "type": "custom",
                "custom_type": "pnlight.cta_button",
                "width": { "type": "match_parent" },
                "height": { "type": "fixed", "value": 56 },
                "custom_props": {
                  "title": "Open offer",
                  "url": "pnlight://navigation/present?route=offer"
                }
              }
            }
          ]
        }
      }
    },
    "offer": {
      "presentation": {
        "style": "sheet",
        "detent": "large",
        "grabber": true,
        "dismissible": true,
        "corner_radius": 28
      },
      "divkit": {
        "templates": {},
        "card": {
          "log_id": "offer",
          "states": [
            {
              "state_id": 0,
              "div": {
                "type": "custom",
                "custom_type": "pnlight.cta_button",
                "width": { "type": "match_parent" },
                "height": { "type": "fixed", "value": 56 },
                "custom_props": {
                  "title": "Dismiss",
                  "url": "pnlight://navigation/dismiss"
                }
              }
            }
          ]
        }
      }
    }
  }
}

Each route contains a complete DivKit document under divkit. The renderer prepares declared routes and consumes these URLs before they reach onAction:

| URL | Native behavior | | --- | --- | | pnlight://navigation/push?route=details | Pushes onto the active private navigation stack. | | pnlight://navigation/pop | Pops the active stack. | | pnlight://navigation/replace?route=details | Replaces the active route. | | pnlight://navigation/pop_to_root | Pops to the first route. | | pnlight://navigation/present?route=offer | Presents a new native modal navigation context over the app. | | pnlight://navigation/dismiss | Dismisses the current PNLight modal context. |

Presented routes can push their own routes without losing the embedded stack's DivKit variables or scroll state. presentation.style accepts "sheet" (the default) or "full_screen". Native sheets support detent ("large" or "medium" with expansion to large), grabber, dismissible, and corner_radius. A present URL may override these fields, for example:

pnlight://navigation/present?route=offer&detent=medium&grabber=false

Navigation URLs in a legacy single-card document continue to reach onAction; PNLight consumes them only inside a valid flow.

Native alerts and action sheets

Put dialogs at the Remote UI document root (beside card, or beside routes in a flow) and trigger one with pnlight://dialog/show?id=<dialog-id>. The package presents a native iOS UIAlertController; the React Native app needs no modal state or extra setup.

{
  "schemaVersion": 2,
  "dialogs": {
    "delete_confirmation": {
      "style": "action_sheet",
      "title": "Delete item?",
      "message": "This cannot be undone.",
      "buttons": [
        { "title": "Cancel", "style": "cancel", "actions": [] },
        {
          "title": "Delete",
          "style": "destructive",
          "actions": [
            {
              "log_id": "delete_confirmed",
              "url": "my-app://delete?id=42"
            }
          ]
        }
      ]
    }
  },
  "card": {}
}

Dialog style is alert or action_sheet; button style is default, cancel, or destructive. Every button owns an ordered array of normal DivKit actions. They run through the current card's DivKit handler, including typed variable/state actions, PNLight navigation and haptics, analytics, and custom URLs delivered through onAction. Empty arrays are dismiss-only.

Native haptics

PNLight consumes pnlight://haptic/... actions before they reach onAction. In schema v2, simple feedback works in both single-card documents and flow routes:

| URL | Native behavior | | --- | --- | | pnlight://haptic/impact?style=light | UIKit impact feedback. Styles: light, medium, heavy, soft, or rigid; optional intensity is clamped to 0...1. | | pnlight://haptic/selection | UIKit selection feedback. | | pnlight://haptic/notification?type=success | UIKit notification feedback. Types: success, warning, or error. |

Flows can also declare named Core Haptics patterns:

{
  "schemaVersion": 2,
  "type": "flow",
  "initial_route": "main",
  "haptics": {
    "ambient_pulse": {
      "events": [
        {
          "type": "continuous",
          "time": 0,
          "duration": 0.8,
          "intensity": 0.25,
          "sharpness": 0.35
        },
        {
          "type": "transient",
          "time": 0.4,
          "intensity": 0.65,
          "sharpness": 0.55
        }
      ],
      "loop": true,
      "max_duration": 120
    }
  },
  "routes": {
    "main": {
      "divkit": {
        "templates": {},
        "card": {
          "log_id": "main",
          "states": [
            {
              "state_id": 0,
              "div": {
                "type": "text",
                "text": "Haptic demo",
                "width": { "type": "match_parent" },
                "height": { "type": "wrap_content" }
              }
            }
          ]
        }
      }
    }
  }
}

Start a named pattern with pnlight://haptic/start?pattern=ambient_pulse and stop it with pnlight://haptic/stop?pattern=ambient_pulse. Calling stop without a pattern stops every active player. Patterns accept 1–128 transient or continuous events. Time values are seconds; time_ms, duration_ms, and max_duration_ms are also accepted. Core Haptics safely does nothing on unsupported hardware, and PNLight stops active players when their route leaves the window or the app enters the background.

Lottie animations

The package installs Lottie and configures DivKit's standard lottie extension. The host app does not install Lottie separately or register a renderer. Add the extension to a fixed-size DivKit element:

{
  "type": "container",
  "width": { "type": "fixed", "value": 200 },
  "height": { "type": "fixed", "value": 200 },
  "items": [],
  "extensions": [
    {
      "id": "lottie",
      "params": {
        "lottie_url": "https://cdn.example.com/animation.json",
        "repeat_count": 0,
        "repeat_mode": "restart",
        "is_playing": true
      }
    }
  ]
}

lottie_json may replace lottie_url with an inline decoded Lottie document. repeat_mode accepts "restart" (the default) or "reverse"; repeat_count: 0 repeats indefinitely; is_playing defaults to true. Remote URLs should use HTTPS. Uncompressed Lottie JSON is supported inline or remotely; .lottie ZIP archives are not.

Native iOS components

PNLight's custom Remote UI components are native UIKit views controlled by DivKit markup. React Native does not recreate them in JavaScript: on iOS, RemoteUiView recognizes their custom_type, renders the native component, and forwards interactive actions through the same onAction callback shown above.

The currently supported custom types are:

  • pnlight.circular_loader
  • pnlight.cta_button
  • pnlight.icon_button
  • pnlight.animated_prepend_list
  • pnlight.progress_bar
  • pnlight.animated_number

These components are iOS-only.

Circular loader

Use DivKit's custom element to render a native UIActivityIndicatorView inside Remote UI markup:

{
  "type": "custom",
  "custom_type": "pnlight.circular_loader",
  "width": { "type": "fixed", "value": 48 },
  "height": { "type": "fixed", "value": 48 },
  "custom_props": {
    "style": "large",
    "color": "#FF007AFF",
    "accessibility_label": "Loading"
  }
}

style accepts "medium" (the default) or "large". color accepts #RRGGBB or DivKit-style #AARRGGBB and defaults to the adaptive iOS label color. accessibility_label defaults to "Loading". Standard DivKit width and height fields control the element's layout; set a dimension to { "type": "wrap_content" } to use the native indicator's intrinsic size for that dimension.

CTA button

Render a native, animated call-to-action button with a repeating shimmer streak, an idle attention pulse, and a spring press bounce. Taps are routed through the same action pipeline as DivKit buttons, so onAction receives the payload decoded from url.

{
  "type": "custom",
  "custom_type": "pnlight.cta_button",
  "width": { "type": "match_parent" },
  "height": { "type": "fixed", "value": 58 },
  "custom_props": {
    "title": "Continue",
    "background_color": "#FF007AFF",
    "title_color": "#FFFFFFFF",
    "corner_radius": 16,
    "font_size": 19,
    "font_weight": "bold",
    "shimmer": true,
    "bounce": true,
    "url": "pnlight://cta?id=continue"
  }
}

All props are optional; only title and url are usually needed. Colors accept #RRGGBB or DivKit-style #AARRGGBB.

Native CTA and icon buttons also support the standard DivKit actions array. Actions execute in declaration order through DivKit's normal action handler, including typed variable/state actions, PNLight haptics, dialogs, navigation, analytics, and custom URLs. A non-empty actions array takes precedence over the custom_props.url / log_id single-action shorthand.

| Prop | Default | Description | | --- | --- | --- | | title | "" | Button label. | | background_color | #FF007AFF | Fill color (also the gradient start). | | background_color_end | — | When set, the fill is a horizontal gradient to this color. | | glass | off (on for icon buttons) | Native Liquid Glass fill on iOS 26+. See below. | | title_color | #FFFFFFFF | Label color. | | corner_radius | 14 | Corner radius in points (continuous curve). | | font_size | 18 | Label point size. | | font_weight | semibold | regular/medium/semibold/bold/heavy/black. | | horizontal_padding / vertical_padding | 24 / 16 | Used to size the button when width/height is wrap_content. | | icon | — | SF Symbol name, e.g. "shield.lefthalf.filled". Renders before the title. | | icon_size | 20 | Symbol point size. | | icon_weight | semibold | ultralight … black. | | icon_color | title_color | Symbol tint. | | icon_spacing | 8 | Gap between icon and title. | | loading | false | Swaps the title for a native spinner and ignores taps. | | disabled | false | Dims the button and ignores taps. | | disabled_alpha | 0.45 | Opacity used while disabled. | | disabled_background_color | — | Replaces the fill (and any gradient) while disabled. | | disabled_title_color | — | Replaces the label color while disabled. | | loading_indicator_color | title_color | Spinner color. | | loading_indicator_style | medium | medium or large. | | url | — | Action fired on tap (custom scheme → onAction; http(s) → opened). | | log_id | — | Emitted as the action's logId when there is no url. | | accessibility_label | title | VoiceOver label (defaults to "Loading" while loading). |

Both loading and disabled make the button inert: taps are ignored and the shimmer and bounce animations stop, so an in-flight CTA sits still.

shimmer and bounce also accept configuration objects. The shimmer object supports enabled, color, duration, pause, band_width, and angle; the bounce object supports idle (enabled, scale, period) and press (enabled, scale). Props may contain DivKit expressions, so values such as loading can be bound to card variables.

On iOS 26+, glass enables native Liquid Glass. It accepts true or an object with enabled, style ("regular" or "clear"), tint, and prominent. prominent selects the filled primary-action treatment. It falls back to the solid fill on earlier iOS versions or when Reduce Transparency is enabled.

For a custom-scheme URL such as pnlight://cta?id=continue, event.params?.id is "continue" in React Native's onAction callback.

Icon button

pnlight.icon_button is the same native button tuned for a small circular control with an SF Symbol—ideal for close, settings, or favorite affordances.

{
  "type": "custom",
  "custom_type": "pnlight.icon_button",
  "width": { "type": "fixed", "value": 44 },
  "height": { "type": "fixed", "value": 44 },
  "custom_props": {
    "icon": "xmark",
    "accessibility_label": "Close",
    "url": "pnlight://cta?id=close"
  }
}

It accepts every pnlight.cta_button prop above, including loading, disabled, shimmer, bounce, and expression binding. Only the defaults differ:

| Prop | CTA default | Icon default | | --- | --- | --- | | shape | corner_radius: 14 | fully circular | | background_color | #FF007AFF | secondarySystemFill (adaptive) | | glass | off | on when no background_color is set | | icon/title color | white | label (adaptive) | | horizontal_padding / vertical_padding | 24 / 16 | 12 / 12 | | shimmer | on | off | | bounce.idle | on | off | | bounce.press | on | on |

Setting corner_radius opts out of the circular shape, giving a rounded square. With wrap_content on both axes, the button sizes itself from the symbol plus padding and stays square.

For accessibility, set accessibility_label on icon-only buttons. It falls back to the SF Symbol name, which is rarely what you want VoiceOver to read. An unknown symbol name is reported in the card's DivKit errors rather than silently rendering an empty button.

Progress bar

pnlight.progress_bar is a native linear progress bar. Give it a new progress (or move the variable behind progress_variable) and it animates from whatever is currently on screen to the new value, so markup no longer has to fake a bar with a stack of DivKit animations.

{
  "type": "custom",
  "custom_type": "pnlight.progress_bar",
  "width": { "type": "match_parent" },
  "height": { "type": "fixed", "value": 10 },
  "custom_props": {
    "instance_id": "scan",
    "progress": 0,
    "progress_variable": "scan_progress",
    "animation_duration": 0.45
  }
}

progress is clamped to 0...1. The bar's thickness is the standard DivKit height; with wrap_content it falls back to track_height. corner_radius defaults to a pill. Colors accept #RRGGBB or DivKit-style #AARRGGBB.

| Prop | Default | Description | | --- | --- | --- | | instance_id | fallback shared ID | Stable identity for retaining fill state; set this explicitly. | | progress | 0 | Target fill, clamped to 0...1. | | progress_variable | "" | DivKit variable name that drives progress reactively across native and web renderers. | | initial_progress | — | Fill the first render starts from, so the bar can animate in on appear. Without it the first value is applied immediately. | | indeterminate | false | Loops a sliding band and ignores progress, for work of unknown length. | | track_color | secondarySystemFill | Track fill. | | track_height | 8 | Thickness used only when the DivKit height is wrap_content. | | fill_color | #FF007AFF | Fill color (also the gradient start). | | fill_color_end | — | When set, the fill is a horizontal gradient to this color. | | corner_radius | pill | Continuous corner radius in points, applied to the track and the fill. | | fill_inset | 0 | Inset between the track's bounds and the fill on every edge. | | animation_duration | 0.3 | Seconds spent travelling to a new value. | | indeterminate_duration | 1.1 | Seconds for one band pass. | | indeterminate_band_width | 0.3 | Band thickness as a fraction of the track width. | | reduced_motion | false | Applies values immediately and holds the indeterminate band still. | | accessibility_label | "Progress" | VoiceOver label; the value is announced as a percentage. |

A long animation_duration is also how a bar fills by itself: set progress to 1 with "animation_duration": 8 and the bar takes eight seconds to get there, with no timer in the markup.

Animated number

pnlight.animated_number is a native label that counts between values. Pass a number variable and it renders every value in between — 0 to 14 counts up rather than snapping.

{
  "type": "custom",
  "custom_type": "pnlight.animated_number",
  "width": { "type": "match_parent" },
  "height": { "type": "wrap_content" },
  "custom_props": {
    "instance_id": "issues",
    "value": 0,
    "value_variable": "issues_found",
    "suffix": " issues found",
    "font_size": 28,
    "font_weight": "bold"
  }
}

| Prop | Default | Description | | --- | --- | --- | | instance_id | fallback shared ID | Stable identity for retaining the displayed value; set this explicitly. | | value | 0 | Target number. | | value_variable | "" | DivKit variable name that drives value reactively across native and web renderers. | | initial_value | — | Value the first render counts from, e.g. 0 for a count-up on appear. Without it the first value is displayed immediately. | | decimals | 0 | Fraction digits, applied as both the minimum and the maximum so the label never changes length mid-count. | | min_integer_digits | 1 | Zero-pads shorter numbers, e.g. 2 renders 07. | | grouping | true | Thousands separators. | | monospaced_digits | true | Tabular figures, so the label does not jitter while counting. | | prefix / suffix | "" | Text placed before/after the number, e.g. "$" or " GB". | | font_size | 34 | Point size. | | font_weight | bold | ultralight/thin/light/regular/medium/semibold/bold/heavy/black. | | text_color | label | Adaptive by default. | | text_alignment | center | left/center/right/natural. | | animation_duration | 0.6 | Seconds spent counting to a new value. | | curve | ease_out | linear/ease_in/ease_out/ease_in_out. | | reduced_motion | false | Applies values immediately, without counting. | | accessibility_label | — | VoiceOver label; the target value is announced as the element's value. |

Numbers are formatted for the device locale, so separators follow the user's region. A value that changes mid-count is picked up from the value currently on screen, and an unrelated variable change never restarts the count.

Both components are schema v3 features. The document must declare "schemaVersion": 3; in a v1/v2 document they report a DivKit error instead of rendering. Give every simultaneously rendered instance a stable, unique instance_id — that identity is what lets the native view keep its animation state across DivKit variable updates.

Use progress_variable / value_variable for variable-driven values so the same JSON is reactive in both the native and the web renderer; keep progress / value as the literal initial value. Every other prop supports DivKit expressions. Both components are implemented by PNLightSDK on iOS and by @pnlight/sdk-react on the web.

Manual Config Fetching

Use getUIConfig if you need to fetch the placement configuration yourself. When attributionRequired is true (the default), the SDK waits for attribution data internally before returning:

import { getUIConfig } from "@pnlight/sdk-react-native";

const config = await getUIConfig("paywall");
const configWithoutAttributionWait = await getUIConfig("paywall", false);

Remote Config

Remote Config gives the app a small, typed key-value document resolved for the current PNLight user. Use it for non-sensitive runtime behavior such as feature flags, copy variants, and limits. Do not put API keys, credentials, or other secrets in Remote Config.

Setup

Pass local defaults when initializing the SDK. They stay on the device; PNLight never receives them. A fetched value wins only when it has the expected type, so every getter remains safe while offline, before the first fetch, or after a server-side type mistake.

import { initialize } from "@pnlight/sdk-react-native";

await initialize("pnlight_sdk_token", undefined, {
  showNewPaywall: false,
  welcomeTitle: "Welcome",
  maxFreeExports: 3,
  enabledCountries: ["US", "GB"],
  paywallStyle: { accent: "purple", compact: true },
});

Fetch and read values

Fetch after initialization. It is persisted per SDK token and user, and an unchanged response is revalidated with ETag. Calls are throttled for 15 minutes by default; use 0 only during local development or QA.

import {
  fetchAndActivate,
  getRemoteConfigBoolean,
  getRemoteConfigNumber,
  getRemoteConfigJSONObject,
  getRemoteConfigString,
  getRemoteConfigStringArray,
} from "@pnlight/sdk-react-native";

try {
  const result = await fetchAndActivate();
  // "activated", "notModified", or "throttled"
  console.log("Remote Config fetch:", result);
} catch (error) {
  // Existing active values/defaults remain available after a failed request.
  console.warn("Remote Config unavailable", error);
}

const showNewPaywall = await getRemoteConfigBoolean("showNewPaywall", false);
const title = await getRemoteConfigString("welcomeTitle", "Welcome");
const limit = await getRemoteConfigNumber("maxFreeExports", 3);
const countries = await getRemoteConfigStringArray("enabledCountries", []);
const style = await getRemoteConfigJSONObject("paywallStyle", {
  accent: "purple",
  compact: false,
});

Attribution-aware overrides

Remote Config overrides can target attribution, exactly as Remote UI can. By default fetchAndActivate() waits up to eight seconds for AppsFlyer attribution before it sends its request. Start AppsFlyer and forward its conversion callback with addAttribution as usual, then fetch Remote Config.

When an immediate base-only result is needed (for example, a startup path that must not wait), opt out explicitly:

await fetchAndActivate(15 * 60, false);

API Reference

SDK Methods

| Method | Description | | ---------------------------------------------- | ------------------------------------------------------------- | | initialize(apiKey, baseDomain?) | Initialize the SDK with your API key and optional base domain | | logEvent(eventName, eventArgs?) | Log a custom event with optional arguments | | addAttribution(provider, data?, identifier?) | Send attribution data from AppsFlyer, Firebase, or Facebook | | getUserId() | Get or create a stable user identifier | | updateIdfa() | Send the current IDFA to PNLight after the ATT prompt completes | | fetchAndActivate(interval?, waitAttribution?) | Fetch and activate typed Remote Config; waits for attribution by default | | getRemoteConfigBoolean/String/Number(key, fallback) | Read a typed scalar with a safe fallback | | getRemoteConfigStringArray/JSONObject(key, fallback) | Read a typed collection or JSON object with a safe fallback | | prefetchUIConfig(placement) | Prefetch a UI config into the in-memory cache | | getUIConfig(placement, attributionRequired?) | Fetch a UI config; waits for attribution by default | | fetchProducts() | Load configured products with App Store price/offer/trial info | | purchase(productId) | Purchase a product; resolves a PNLightPurchaseResult | | restorePurchases() | Restore previous purchases by syncing with the App Store | | isPremium() | Whether the user has an active entitlement to any configured product | | isPurchased(productId) | Whether the user has an active entitlement to a specific product | | isEligibleForTrial(productId) | Whether the user is eligible for a product's introductory offer | | getAppleReceipt() | Base64 App Store receipt for server-side validation, or null if absent |

RemoteUiView

| Prop | Type | Description | | --------------------- | ------------------------------ | ----------------------------------------------------------------------- | | placement | string | PNLight placement identifier | | style | StyleProp<ViewStyle> | Optional React Native style | | secure | boolean | Deprecated. Secure rendering is controlled by the backend response. | | preventRecording | boolean | Deprecated. Capture blocking is controlled by the backend. | | attributionRequired | boolean | Wait for attribution before returning config; defaults to true | | onAction | (event: ActionEvent) => void | Called when a custom action is triggered | | onPurchased | (event: PurchasedEvent) => void | Called with { productId } after this view verifies a Remote UI purchase | | onClosed | () => void | Called when the document runs pnlight://close; close the screen here |

ActionEvent

| Property | Type | Description | | -------- | ------------------------ | ------------------------------------------------- | | url | string | Full URL string of the triggered action | | scheme | string | URL scheme | | path | string | URL path component | | params | Record<string, string> | Query parameters extracted from the URL | | logId | string \| undefined | Log ID for the triggered action | | action | string \| undefined | Raw action value when provided by the native view |

UIConfig

| Property | Type | Description | | ------------ | ------------------------------ | ---------------------------------------- | | config | string \| null | Remote UI JSON config | | parameters | Record<string, any> \| null | Placement parameters returned by PNLight | | debug | boolean \| null \| undefined | Debug flag returned by PNLight | | secure | boolean \| null \| undefined | Secure rendering flag returned by PNLight | | deferHomeGesture | boolean \| null \| undefined | Requests bottom-edge Home gesture deferral for schema v8 Remote UI |


Support

For support and questions, visit docs.pnlight.app.