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

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.

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 install

Configure 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 Activity

Refreshing

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 the fullColor PNG 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