react-native-rasterized-widgets
v0.1.0
Published
Build iOS home-screen and lock-screen widgets in pure React Native — main app pre-renders each widget via Fabric and snapshots it to a PNG; the widget extension displays the image edge-to-edge.
Maintainers
Readme
react-native-rasterized-widgets
iOS only. WidgetKit is iOS-exclusive; this package has no Android equivalent. Android app widgets use a different model and would need a separate rendering pipeline.
Build iOS home-screen and lock-screen widgets in pure React Native. The main app pre-renders each widget offscreen via Fabric and snapshots it to a PNG; the widget extension displays the image edge-to-edge.
Public API mirrors expo-widgets
so you can migrate between native-SwiftUI and rasterized rendering with
minimal code churn. expo-widgets is a required peer dependency — we
re-export its types (WidgetFamily, WidgetEnvironment,
WidgetTimelineEntry) and reuse its internal Xcode plugin utility to
register the widget extension target.
Why raster?
- Full React Native — any library, any layout, any style. If it runs in your app, it runs in your widget.
- No custom runtime — no Hermes in the extension, no view-spec interpreter, no Yoga or Skia required.
- One mental model — a widget is a React component. Call
updateSnapshot(props)to re-render and save a PNG. - iOS 18 Tinted / Clear / Vibrant support — ship mode-specific alpha variants alongside the full-color bitmap; iOS picks the right one per rendering mode. See Rendering modes below — this is a raster-widget-specific feature, no analog in expo-widgets.
Tradeoffs vs native-SwiftUI widgets
- Static image per refresh. No per-frame animation in widgets.
- Main app must run to render. Typical triggers: on launch, on data-changed, from a background fetch, or from push via your normal app-level notification handling.
- Tap handling is deep-link only — see below. Interactive widgets (in-extension button intents) aren't supported because our re-render runs in the main app, not the extension.
Install
npm install react-native-rasterized-widgets expo-widgets
cd ios && pod installConfigure the Expo plugin (props shape matches expo-widgets):
{
"plugins": [
["react-native-rasterized-widgets", {
"bundleIdentifier": "com.example.myapp.widgets",
"groupIdentifier": "group.com.example.myapp",
"widgets": [
{
"name": "Activity",
"displayName": "Activity",
"description": "Your daily activity at a glance",
"component": "./widgets/Activity.tsx",
"supportedFamilies": ["systemSmall", "systemMedium"],
"supportedRenderingModes": ["fullColor", "accented"],
"accentedRenderingMode": "fullColor"
},
{
"name": "BatteryLevel",
"displayName": "Battery",
"component": "./widgets/BatteryLevel.tsx",
"supportedFamilies": ["accessoryCircular", "accessoryRectangular"],
"supportedRenderingModes": ["fullColor", "vibrant"]
}
]
}]
]
}Then expo prebuild + expo run:ios.
Defining a widget
// widgets/Activity.tsx
import React, { useEffect, useState } from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { Widget } from 'react-native-rasterized-widgets'
import { useWidgetReady } from 'react-native-rasterized-widgets/components'
// Matches expo-widgets' Widget(name, layout) constructor.
// Layout is a regular React component — no 'widget' Babel directive.
const Activity = new Widget('Activity', (props, env) => {
const [steps, setSteps] = useState<number | null>(null)
const markReady = useWidgetReady()
useEffect(() => {
fetchSteps().then(n => { setSteps(n); markReady() })
}, [markReady])
if (steps == null) return <View style={styles.loading}><Text>…</Text></View>
return (
<View style={styles.container}>
<Text style={styles.count}>{steps}</Text>
<Text style={styles.unit}>steps · {env.widgetFamily}</Text>
</View>
)
})
// Optional: deep link opened on tap. Omit for a no-op-on-tap widget.
Activity.widgetURL = 'myapp://activity'
export default ActivityRefreshing
Methods match expo-widgets:
import Activity from './widgets/Activity'
// Set new props and re-render (call this when you have new data)
await Activity.updateSnapshot({ steps: 8421 })
// Tell WidgetKit to invalidate the timeline — no re-render. Useful if
// you've updated shared state some other way and want the widget to
// re-read its current PNG.
await Activity.reload()
// Scheduled updates — renders one PNG per entry × family and writes a
// timeline descriptor. WidgetKit picks the right entry based on `date`.
await Activity.updateTimeline([
{ date: new Date(), props: { steps: 100 } },
{ date: addMinutes(new Date(), 30), props: { steps: 200 } },
])
// Inspect (same shape as expo-widgets: { date: Date, props }[])
const entries = await Activity.getTimeline()
// Forget the schedule (also deletes the PNGs it referenced)
await Activity.clearTimeline()Concurrent updateSnapshot / updateTimeline / clearTimeline calls
against the same widget are serialized internally, so background-fetch
triggers and user-driven refreshes can't race the timeline descriptor.
Tap handling
Tap = whole-widget deep link. Set widget.widgetURL to the URL you
want iOS to open:
Activity.widgetURL = 'myapp://activity'
// or a function of props:
Activity.widgetURL = (p) => `myapp://activity/${p.id}`iOS opens your app at that URL on tap — expo-router handles this
natively, no extra wiring. Without expo-router, use
Linking.addEventListener('url', ...).
If widgetURL is unset, tapping the widget is a no-op (consistent with
a static image).
Why no in-extension button intents?
iOS 17+ lets you wrap widget views in Button(intent: AppIntent) to
handle taps silently. perform() runs in the widget extension process,
not the host app. For this package that's a dead end: re-rendering a
widget requires the main app's Fabric runtime, which the extension
doesn't have. Deep links are the only way to route taps into the main
app's JS. (expo-widgets works around this by embedding a
JavaScriptCore runtime in the extension and running your tap handler
there — we don't.)
Sizing
Widget dimensions vary per device. The package ships Apple's HIG
dimensions table (keyed by Dimensions.get('screen')), so widgets
render with device-correct dims on first install — no waiting for the
user to physically place the widget before anything useful appears.
The widget extension also records the actual rendered size into the
shared App Group via GeometryReader on first display; subsequent
renders use that recorded value to correct any per-device drift from
the HIG table.
Lock screen widgets
Lock screen accessory families (accessoryCircular,
accessoryRectangular, accessoryInline) work out of the box — list
them in supportedFamilies. iOS renders these as monochrome / tinted
so keep designs high-contrast and minimal. See Rendering modes
below for how to ship a "vibrant" variant.
Rendering modes (iOS 18 Tinted / Clear + lock screen)
iOS 18 added home-screen Tinted and Clear customization; lock
screen widgets have always been Vibrant. In these modes iOS
discards your image's RGB and uses only alpha to derive a silhouette
that it colors with the system accent (or white on lock screen).
Full-color PNGs rendered for fullColor become white blobs.
To ship mode-specific variants:
{
"supportedRenderingModes": ["fullColor", "accented"],
"accentedRenderingMode": "fullColor"
}supportedRenderingModes— which variants to render. Default["fullColor"]. Adding"accented"makes the package render a second PNG per family for iOS 18 Tinted mode. Adding"vibrant"does the same for lock screen.accentedRenderingMode— fallback applied to thefullColorPNG when iOS is in accented context and no accented variant was rendered. Options:"fullColor"(keep original RGB, ignore tinting — what most apps want) /"accented"(iOS default — tint as silhouette) /"accentedDesaturated"/"desaturated". Default"accented".
Read the current mode in your widget layout via env.widgetRenderingMode
and branch:
const Activity = new Widget('Activity', (props, env) => {
const isAlpha = env.widgetRenderingMode !== 'fullColor'
return (
<View style={{
flex: 1,
backgroundColor: isAlpha ? 'transparent' : '#0b1120',
}}>
{/* Convey info via opacity, not hue — iOS colors the silhouette */}
<Text style={{
color: 'white',
opacity: isAlpha ? 0.9 : 1,
}}>
{props.steps}
</Text>
</View>
)
})Rules of thumb for alpha variants:
- Solid backgrounds → transparent (let home screen / lock screen show through, or get filled by system tint).
- Hue variation → opacity variation (can't use color anyway — iOS throws RGB away).
- Text stays opaque white — iOS replaces the color with the accent/tint, so fully-opaque white text picks up the full tint.
- Progress bars / thin lines — keep them simple; silhouette has to read at a glance.
Relationship to expo-widgets
This package is not a drop-in replacement — the layout function
can't use the 'widget' directive, and the backing rendering strategy
is fundamentally different. But the public API shape is kept as close
as possible:
| API | expo-widgets | rasterized-widgets |
| --- | --- | --- |
| new Widget(name, layout) | ✓ | ✓ (layout is a real RN component, no directive) |
| .reload() / .updateSnapshot() / .updateTimeline() / .getTimeline() | ✓ | ✓ |
| .clearTimeline() | — | ✓ (raster-only convenience; updateTimeline([]) also works) |
| WidgetFamily / WidgetEnvironment / WidgetTimelineEntry | ✓ | re-exported from expo-widgets |
| Plugin props (bundleIdentifier, groupIdentifier, widgets[]) | ✓ | ✓ |
| 'widget' Babel directive | ✓ (required) | ✗ (N/A for raster) |
| widgetURL on instance | ✗ | ✓ (raster-only: deep link on tap) |
| Interactive button intents | ✓ (JSC handler in extension) | ✗ (use widgetURL instead) |
| iOS 18 Tinted / Clear mode variants | ✗ | ✓ (supportedRenderingModes + alpha-aware widget layout) |
| Android | ✗ (iOS-only) | ✗ (iOS-only) |
License
MIT
