mantis-recommender-react-native
v1.5.0
Published
React Native component library for Mantis content recommendations
Readme
mantis-recommender-react-native
A React Native component library for rendering Mantis content recommendations as a horizontal carousel or a vertical infinite-scroll feed, with built-in theming, tracking, ad slots, and error handling.
Note: This library requires an active integration with the Mantis Intelligence Recommender API. You'll need a valid API endpoint and component ID provided by Mantis Solutions to fetch recommendations. Contact your Mantis account manager for access.
Installation
npm install mantis-recommender-react-nativePeer Dependencies
Ensure your project has the following peer dependencies installed:
npm install react react-nativeQuick Start
Get recommendations rendering in under 5 minutes:
import React from 'react';
import { SafeAreaView } from 'react-native';
import { MantisRecommender } from 'mantis-recommender-react-native';
export default function App() {
return (
<SafeAreaView style={{ flex: 1 }}>
<MantisRecommender
apiUrl="<RECOMMENDATIONS_API_URL>"
componentId="mantis-ui-widget-1"
url="<PAGE_URL>
title="Recommended for you"
onItemPress={(url) => console.log('Tapped:', url)}
/>
</SafeAreaView>
);
}Both named and default imports are supported:
// Named import (recommended)
import { MantisRecommender } from 'mantis-recommender-react-native';
// Default import
import MantisRecommender from 'mantis-recommender-react-native';Render Modes
The same component renders either a horizontal carousel or a vertical infinite-scroll feed, selected with the mode prop. Both modes share the API client, tracking, theming, and ad infrastructure.
| Mode | Layout | Data loading |
| ---------------------- | ------------------------------------- | ------------------------------------- |
| 'carousel' (default) | Horizontal snap-scrolling cards | Single fetch |
| 'feed' | Vertical FlatList, full-width cards | Paginated, fetches more as you scroll |
Feed Mode
<MantisRecommender
apiUrl="<RECOMMENDATIONS_API_URL>"
componentId="mantis-ui-widget-1"
url="<PAGE_URL>"
mode="feed"
pageSize={10}
feedCardLayout="stacked"
onItemPress={(url) => Linking.openURL(url)}
/>Feed mode gives you:
- Infinite scroll — the next page is fetched when the list is within half a screen of the end (
onEndReachedThreshold={0.5}) - Pull-to-refresh — resets pagination and reloads from the first page
- Loading indicator at the bottom of the list while the next page is in flight
- Impression tracking — fires when a card is at least 50% visible, same as the carousel
- Performance defaults —
keyExtractoron stable item identity,initialNumToRender/maxToRenderPerBatch/windowSizeof 10, andremoveClippedSubviews
Pagination uses offset-based fetching against /recommender/agnostic: each request sends limit=pageSize and an offset advanced by the number of items received. The feed stops requesting more once a page returns fewer items than pageSize.
The feed must be given a bounded height by its parent (for example a flex container or SafeAreaView with flex: 1), as the underlying FlatList fills the space available to it.
Feed Card Layouts
feedCardLayout switches the card variant used in feed mode:
| Layout | Rendering |
| --------------------- | ---------------------------------------------------------------- |
| 'stacked' (default) | Full-width 16:9 image above the title, brand, and timestamp |
| 'compact' | 120x90 thumbnail on the left, title/brand/timestamp on the right |
Each layout applies its own theme defaults on top of the base theme — see Feed Theming.
A timestamp is rendered below the title when the API response includes timestamp on the item. The value is displayed as returned by the API; the library does not reformat it. When absent, the timestamp is omitted.
Ads in the Feed
Ad slots are placed into the feed by the same infrastructure as the carousel. Set adsEnabled to opt in — positions come from adIntent.slots in the API response, not from a fixed interval:
<MantisRecommender
mode="feed"
adsEnabled={true}
onAdImpression={(slot) => console.log('Ad shown:', slot.divId)}
onAdClicked={(slot) => console.log('Ad clicked:', slot.divId)}
...
/>Behaviour worth knowing:
- Only MPU slots (
type: 'mpu') are placed. Each slot's ownpositionfield is authoritative and is 1-based within the page it was fetched with, so ads recur at the same relative positions on every page. - Slots whose position falls beyond the end of a page are dropped rather than clamped to the end.
- Because the API returns the same
adIntentfor every page, each page's slots are given a derived identity (divIdsuffixed with-p{pageIndex}) so GAM sees distinct placements.targeting.positionCounteris set to the ad's position in the whole feed. - Ads render lazily with the list, so off-screen slots are not requested until their row is rendered.
- While a slot is loading, a placeholder sized from the slot's first requested size is shown. On no-fill the slot renders nothing, so no ad-sized gap is left behind (only the row's bottom margin remains).
MantisAdSlot, the useAdSlots hook, and buildFeedWithAds are exported if you need to compose feeds yourself.
Props API Reference
Required Props
| Prop | Type | Description |
| ------------- | -------- | ----------------------------------------------------------- |
| apiUrl | string | Recommendations API endpoint provided for your environment. |
| componentId | string | Unique identifier for this recommender instance |
| url | string | Page URL to get recommendations for |
Optional — API Parameters
| Prop | Type | Default | Description |
| ------------------- | ---------- | ------- | ------------------------------------------ |
| limit | number | — | Maximum number of recommendations to fetch |
| offset | number | — | Pagination offset |
| recommenderType | string | — | Type of recommender algorithm |
| adsEnabled | boolean | — | Enable ad slots in recommendations |
| adSlots | number[] | — | Positions for ad slots |
| age | number | — | Content age filter |
| tags | string[] | — | Filter by content tags |
| topics | string[] | — | Filter by content topics |
| language | string | — | Language filter |
| domain | string | — | Domain filter |
| subType | string | — | Content subtype filter |
| requireThumbnails | boolean | — | Only return items with thumbnails |
| mantisDebug | boolean | — | Enable debug mode |
Optional — Layout & Feed
| Prop | Type | Default | Description |
| ---------------- | ------------------------ | ------------ | ------------------------------------------------------------------ |
| mode | 'carousel' \| 'feed' | 'carousel' | Render as a horizontal carousel or a vertical infinite-scroll feed |
| pageSize | number | 5 | Items fetched per page in feed mode |
| feedCardLayout | 'stacked' \| 'compact' | 'stacked' | Card variant used in feed mode |
pageSize and feedCardLayout are ignored when mode is 'carousel'.
Optional — Carousel Card Sizing
| Prop | Type | Default | Description |
| ----------------- | -------- | ------- | ----------------------------------------------------------- |
| cardWidth | number | 250 | Width of each carousel card |
| cardHeight | number | 280 | Height of each carousel card |
| cardImageHeight | number | 140 | Height of the image inside each card (spans the card width) |
These are ignored when mode is 'feed'. The image always spans the full card
width, so changing cardWidth resizes the image with it.
<MantisRecommender
apiUrl={apiUrl}
componentId={componentId}
url={url}
cardWidth={320}
cardHeight={360}
cardImageHeight={180}
/>Optional — Theming
| Prop | Type | Default | Description |
| ------------- | -------------------------- | ---------------------------- | ------------------------------------------------- |
| theme | 'light' \| 'dark' | 'light' | Built-in theme preset |
| title | string | 'Similar articles to this' | Section title displayed above the recommendations |
| customTheme | DeepPartial<MantisTheme> | — | Partial theme overrides (deep-merged with base) |
Optional — Event Callbacks
| Prop | Type | Description |
| ------------------ | -------------------------------------------- | ------------------------------------------------------ |
| onDataRequested | () => void | Fired when a fetch begins |
| onDataProcessed | (data: DataResponseAgnostic) => void | Fired on successful fetch |
| onItemPress | (url: string) => void | Fired when a recommendation card is tapped |
| onError | (error: ErrorDataAgnosticResponse) => void | Fired on fetch error |
| onAdImpression | (slot: AgnosticAdSlot) => void | Fired when an ad slot records a GAM impression |
| onAdClicked | (slot: AgnosticAdSlot) => void | Fired when an ad slot is clicked |
| onImpression | (event: TrackingEvent) => void | Fired when a card becomes visible (50%+ threshold) |
| onRendered | (event: TrackingEvent) => void | Fired once after items render successfully |
| trackingProvider | (event: TrackingEvent) => void | Universal tracking callback — receives all event types |
Optional — Secure Tracking
| Prop | Type | Default | Description |
| --------------------------- | --------- | -------------- | ----------------------------------------------------------- |
| secureEnabled | boolean | false | Enable secure server-side tracking |
| secureApi | string | — | Secure API URL (passed to config) |
| secureApiUrl | string | Production URL | Secure Tracking API endpoint provided for your environment. |
| customerId | string | — | Customer identifier for secure tracking |
| impressionTrackingEnabled | boolean | — | Enable impression tracking |
| engagementSecuredEnabled | boolean | — | Enable engagement secured tracking |
Ref Methods
Access imperative methods via a ref:
import React, { useRef } from 'react';
import { MantisRecommender, type MantisRecommenderHandle } from 'mantis-recommender-react-native';
function App() {
const ref = useRef<MantisRecommenderHandle>(null);
return (
<>
<MantisRecommender ref={ref} apiUrl="..." componentId="..." url="..." />
<Button title="Refresh" onPress={() => ref.current?.refetch()} />
</>
);
}| Method | Description |
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
| refetch() | Triggers a new fetch, showing the loading state again. In feed mode this also resets pagination to the first page |
Theming
Built-in Themes
Two themes are available out of the box:
// Light theme (default)
<MantisRecommender theme="light" ... />
// Dark theme
<MantisRecommender theme="dark" ... />Custom Theme Overrides
Use customTheme to partially override any theme token. Overrides are deep-merged with the selected base theme:
<MantisRecommender
theme="light"
customTheme={{
colors: {
cardBackground: '#F8F9FA',
brandText: '#0066CC',
},
borderRadius: {
card: 12,
},
typography: {
titleFontSize: 18,
},
}}
...
/>Theme Structure
interface MantisTheme {
colors: {
placeholderBackground: string;
titleText: string;
brandText: string;
brandColor: string;
cardBackground: string;
navigationButtonBackground: string;
navigationButtonIcon: string;
navigationButtonDisabledBackground: string;
navigationButtonDisabledIcon: string;
emptyStateText: string;
emptyStateBackground: string;
loadingIndicator: string;
loadingBackground: string;
errorText: string;
errorBackground: string;
};
typography: {
titleFontSize: number;
brandLabelFontSize: number;
navigationIconFontSize: number;
emptyStateTextFontSize: number;
};
spacing: {
containerPadding: number;
contentGap: number;
// Feed-specific — see Feed Theming below
cardPadding?: number;
compactImageWidth?: number;
compactImageHeight?: number;
imageAspectRatio?: number;
};
borderRadius: {
card: number;
navigationButton: number;
};
fonts: {
// Applied to card titles and the carousel header title
titleFontFamily?: string;
// Applied to brand labels, the "POWERED BY" label, timestamps and descriptions
bodyFontFamily?: string;
// Optional weight overrides — useful for fonts shipping a dedicated bold file
titleFontWeight?: TextStyle['fontWeight'];
bodyFontWeight?: TextStyle['fontWeight'];
};
}Feed Theming
When mode="feed", layout-specific defaults are applied on top of the base light/dark theme, then your customTheme is merged last — so anything below can be overridden.
| Layout | Applied defaults |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| 'stacked' | spacing.imageAspectRatio: '16 / 9' |
| 'compact' | typography.titleFontSize: 20, spacing.cardPadding: 16, spacing.compactImageWidth: 120, spacing.compactImageHeight: 90 |
To change feed card styling, override the same tokens:
<MantisRecommender
mode="feed"
feedCardLayout="compact"
customTheme={{
spacing: {
cardPadding: 12,
compactImageWidth: 140,
compactImageHeight: 105,
},
typography: {
titleFontSize: 18,
brandLabelFontSize: 11, // also sizes the timestamp
},
borderRadius: {
card: 8,
},
}}
...
/>Carousel mode ignores these tokens — its cards use a fixed 250x140 image.
Custom Fonts
The carousel renders in the system font by default. To use your own brand font, pass font family
names through the theme's fonts section. The library never bundles or downloads font files — the
host app is always responsible for loading the font — the component only references the family name.
| Field | Applies to |
| ----------------- | ----------------------------------------------------------------- |
| titleFontFamily | Card titles and the carousel header title |
| bodyFontFamily | Brand labels, the "POWERED BY" label, timestamps and descriptions |
| titleFontWeight | Optional weight override for title text |
| bodyFontWeight | Optional weight override for body text |
Once a font is loaded and registered by the host app, pass its family name via customTheme:
<MantisRecommender
theme="light"
customTheme={{
fonts: {
titleFontFamily: 'Inter-Bold',
bodyFontFamily: 'Inter',
},
}}
...
/>When fonts is omitted, the system font is used — this is a non-breaking default.
Loading a cloud-hosted font at runtime
To load a font hosted on a CDN (rather than bundling it in the app), download and register it at
runtime, then pass the resolved family name into the theme. Because a bare React Native app can't
register a downloaded font without a native module, use
@brandingbrand/react-native-dynamic-fonts
(no Expo required):
cd example
npm install @brandingbrand/react-native-dynamic-fonts base64-js
# then rebuild the native app so the module is linked (npx expo prebuild / pod install)The example app ships a useRemoteFonts hook (see example/hooks/useRemoteFonts.ts)
that fetches each URL, base64-encodes it, registers it, and returns the family names ready for the theme:
import { useRemoteFonts } from './hooks/useRemoteFonts';
function App() {
const { fonts } = useRemoteFonts({
title: 'https://cdn.yourbrand.com/fonts/Inter-Bold.ttf',
body: 'https://cdn.yourbrand.com/fonts/Inter-Regular.ttf',
});
return <MantisRecommender customTheme={{ fonts }} ... />;
}Caveats when loading fonts on native:
.ttf/.otfonly. WOFF/WOFF2 (what the Google Fonts CSS endpoint serves) cannot be registered by the native iOS/Android font APIs. Host a raw.ttfor.otffile.- iOS uses the PostScript name. On iOS the registered family name is the font's embedded
PostScript name, not the name you request. Always feed the value returned by the loader into the
theme (the
useRemoteFontshook does this for you) rather than hard-coding a family string. - Graceful fallback. Rendering falls back to the system font while the font loads and if loading fails, so the carousel is never blocked.
Using the Theme Context Directly
For advanced use cases, access the resolved theme in your own components:
import { MantisThemeProvider, useMantisTheme } from 'mantis-recommender-react-native';
function CustomOverlay() {
const theme = useMantisTheme();
return <View style={{ backgroundColor: theme.colors.cardBackground }} />;
}
// Wrap with the provider
<MantisThemeProvider themeName="dark" customTheme={myOverrides}>
<CustomOverlay />
</MantisThemeProvider>;Tracking Integration
Event Types
The trackingProvider callback receives a discriminated union of events:
| Event Type | Payload Fields | When Fired |
| --------------- | ---------------------------------------------- | ------------------------------- |
| dataRequested | componentId, timestamp | Fetch begins |
| dataProcessed | componentId, timestamp, data | Fetch succeeds |
| rendered | componentId, timestamp | Items render for the first time |
| impression | componentId, timestamp, item, position | Card becomes 50%+ visible |
| itemPress | componentId, timestamp, item, position | Card is tapped |
| error | componentId, timestamp, error | Fetch fails |
Example: Logging All Events
<MantisRecommender
apiUrl="<RECOMMENDATIONS_API_URL>"
componentId="mantis-ui-widget-1"
url="<PAGE_URL>"
trackingProvider={(event) => {
console.log(`[${event.eventType}]`, event);
}}
onItemPress={(url) => Linking.openURL(url)}
/>Secure Server-Side Tracking
Enable secure tracking to send events to the Mantis secure tracking API:
<MantisRecommender
secureEnabled={true}
secureApiUrl="<SECURE_TRACKING_API_URL>"
customerId="my-customer-id"
domain="<YOUR_DOMAIN.COM>"
...
/>Environment Configuration
Environment-specific API endpoints are provided separately during onboarding.
For environment-specific configuration details, including Recommendations API and Secure Tracking API endpoints, please contact your account manager.
Pass the endpoints provided for your environment via the apiUrl (Recommendations API) and secureApiUrl (Secure Tracking API) props.
The library appends /recommender/agnostic automatically to the apiUrl value.
Note: If
secureApiUrlis omitted, the SDK behavior remains unchanged and will use its default configuration.
Component Layout
The MantisRecommender renders with a header/main/footer structure:
- Header — Title (uppercase, left-aligned) + "Powered by" Mantis logo (right-aligned, theme-aware)
- Main — The active content state (see below)
- Footer — Reserved for future use
Component States
The main section handles four states automatically, in both render modes:
- Loading — Shows a themed
LoadingSpinnerwhile the first page is fetching - Success — Renders either a horizontal
RecommendationCarousel(snap-to-page scrolling with navigation buttons) or a verticalRecommendationFeed, depending onmode - Empty — Shows a themed
EmptyStatewhen the API returns zero items - Error — Shows a themed
ErrorStatewith the error message
Feed mode adds two states below the list:
- Loading more — An activity indicator in the list footer while the next page is fetching
- End of feed — When a load-more request fails, the footer shows "No More Recommendations" and further load-more attempts stop. The initial-load error state is unaffected: already-loaded items stay on screen.
Pull-to-refresh clears the error and reloads from the first page.
Network Behavior
- Timeout: 10 seconds per request
- Retries: Up to 2 retries on non-404 failures
- Backoff: 1s, then 2s between retries
- 404 responses are treated as errors (no retry)
Example App
See the example/ directory for a working Expo app that demonstrates all features including theme switching, event logging, and error states.
Prerequisites
- Node.js 22 LTS (recommended for Expo compatibility)
- Xcode (for iOS simulator) or a physical device with Expo Go
Running the Example
cd example
npm install
npx expo start --clearThen press w to open in the browser, or i for iOS simulator.
The example app imports the library source directly from ../src via Metro's watchFolders, so any changes to the library are picked up automatically — no rebuild or sync step needed.
Building & Publishing
Build
npm run buildCompiles TypeScript source from src/ to CommonJS in dist/ with type declarations.
Publish
npm publishThe prepublishOnly script runs the build automatically before publishing.
Package Contents
The published package includes only dist/ and README.md (~16 kB).
Development
# Install dependencies
npm install
# Build the library
npm run build
# Run tests (requires Node.js 24+)
npm run test:unit
# Run tests with coverage
npm run test:unit:coverage
# Lint & format check
npm run lint:check
# Auto-fix lint & formatting
npm run lint:fixNode.js Version Requirements
| Task | Node.js Version | | ------------------ | --------------- | | Unit tests (Jest) | 24+ | | Example app (Expo) | 22 LTS | | Build & publish | 22+ |
License
MIT — see LICENSE
