Expo Widgets: iOS Home-Screen Widgets Without Touching SwiftUI
If you have ever wanted to put a real iOS home-screen widget into your Expo app, you have probably hit the same wall everyone hits: widgets live in WidgetKit, WidgetKit speaks SwiftUI, and SwiftUI runs in a separate Xcode app-extension target that has nothing to do with your React Native runtime. For a JavaScript-first team, that means learning a new language, maintaining a native target by hand, and quietly breaking the "no Xcode" promise that made Expo appealing in the first place.
expo-widgets is the official Expo SDK module that closes that gap. It lets you declare iOS widgets and Live Activities using React and @expo/ui components, then generates the WidgetKit extension, App Group entitlement, and data plumbing for you through a config plugin. The headline is simple but genuinely new: this is the only option where the widget layout itself is written in React, not just triggered from JavaScript while a Swift file does the real work.
What Makes It Different
There are a few ways to ship a widget alongside a React Native app, and most of them still hand you a Swift file. What sets the official module apart is worth spelling out:
- React layouts, not SwiftUI. Widget and Live Activity UI is written with
@expo/uiSwiftUI bindings (VStack,Text, modifiers) directly in TypeScript. - Full WidgetKit family coverage. Small, medium, large, and extra-large home-screen widgets, plus Lock-Screen accessories and the Dynamic Island.
- Live Activities included. Lock-Screen activities and Dynamic Island layouts, with push-to-start and per-activity APNs tokens.
- Timeline and snapshot updates from JavaScript. Push data immediately or schedule timed entries without dropping into native code.
- Environment-aware rendering. Components receive widget family, color scheme, luminance, and date so a single component adapts to where it is shown.
- It fits the managed workflow. Everything is wired up through a config plugin, so it plays nicely with Continuous Native Generation and EAS builds.
A quick reality check before you get attached: this module is iOS only, it requires a development build (it does not work in Expo Go), and it only became stable in Expo SDK 56 after shipping as an alpha in SDK 55. It is first-party and actively maintained, but it is also young.
Getting It Into Your Project
Because expo-widgets ships in lockstep with the Expo SDK, install it with the Expo CLI so you get the version matched to your SDK:
npx expo install expo-widgets
If you prefer to reach for your package manager directly:
npm install expo-widgets
# or
yarn add expo-widgets
You will need Expo SDK 56 or newer for the stable experience, and the module depends on @expo/ui for the layout components. Since widgets compile into a native extension target, you also need to create a development build rather than using Expo Go. Any change to the config plugin requires a fresh native build.
Drawing Your First Widget in React
A widget is a component plus a registration call. The component is marked with a 'widget' directive, similar in spirit to 'use client', which tells the build that this code is compiled and rendered in the widget extension context rather than in your app's JavaScript runtime.
import { createWidget, type WidgetEnvironment } from "expo-widgets";
import { VStack, Text } from "@expo/ui/swift-ui";
type Props = { count: number };
function CounterWidget(props: Props, env: WidgetEnvironment) {
"widget";
return (
<VStack>
<Text>Count</Text>
<Text>{String(props.count)}</Text>
</VStack>
);
}
export default createWidget("CounterWidget", CounterWidget);
The first argument to createWidget is a Swift-safe identifier (no spaces) used to name the generated target, and the second is your component. The env argument is a WidgetEnvironment object that exposes details like widgetFamily, colorScheme, date, and isLuminanceReduced, so the same component can render a compact layout on a small widget and a richer one on a large widget.
Telling The Plugin About Your Widget
The component is only half the story. The config plugin generates the actual extension target, and you describe each widget in app.json (or app.config.ts). This is also where the App Group identifier that lets your app and the extension share data gets configured.
{
"expo": {
"plugins": [
[
"expo-widgets",
{
"groupIdentifier": "group.com.acme.app",
"enablePushNotifications": true,
"widgets": [
{
"name": "CounterWidget",
"displayName": "Counter",
"description": "Shows your current count",
"supportedFamilies": ["systemSmall", "systemMedium"]
}
]
}
]
]
}
}
The name here must match the identifier you passed to createWidget. supportedFamilies controls which sizes the system offers users, from systemSmall (2x2) through systemLarge (4x4) and the Lock-Screen accessories like accessoryCircular and accessoryRectangular. The groupIdentifier defaults to group.<bundleId>, but setting it explicitly and consistently is wise, since this is the channel through which shared data flows.
Pushing Fresh Data From Your App
Defining a widget is static; the interesting part is updating it from your running app. The widget instance returned by createWidget exposes a small set of methods for exactly that. The simplest is updateSnapshot, which reflects new props on the home screen right away with no scheduling involved.
import CounterWidget from "./CounterWidget";
// Reflect new data on the home screen immediately
CounterWidget.updateSnapshot({ count: 42 });
When you want WidgetKit to roll through a series of timed states instead, schedule a timeline. This is the right tool for anything that changes on a known cadence, like a countdown or a daily summary, because it lets the system render future states without waking your app.
const now = Date.now();
CounterWidget.updateTimeline([
{ date: new Date(now), props: { count: 42 } },
{ date: new Date(now + 60_000), props: { count: 43 } },
{ date: new Date(now + 120_000), props: { count: 44 } },
]);
// Inspect or force a refresh when you need to
const entries = CounterWidget.getTimeline();
CounterWidget.reload();
Snapshots are immediate and cheap; timelines respect WidgetKit's refresh budget and are ideal when the future is predictable. Most apps end up using both: a snapshot when the user does something now, and a timeline to keep the widget alive between launches.
Bringing Live Activities And The Dynamic Island To Life
Live Activities are where this module starts to feel like a superpower. A Live Activity is the glanceable card on the Lock Screen and the compact presentation in the Dynamic Island that you see for ride ETAs, food delivery, sports scores, and timers. createLiveActivity returns a factory you can start, update, and end from JavaScript.
import { createLiveActivity } from "expo-widgets";
import { Text } from "@expo/ui/swift-ui";
type Ride = { driver: string; etaMinutes: number };
const RideActivity = createLiveActivity("RideActivity", (props: Ride) => {
"widget";
return {
banner: (
<Text>
{props.driver} arriving in {props.etaMinutes} min
</Text>
),
compactLeading: <Text>{props.etaMinutes}m</Text>,
minimal: <Text>{props.etaMinutes}</Text>,
};
});
const activity = RideActivity.start({ driver: "Sam", etaMinutes: 5 });
activity.update({ driver: "Sam", etaMinutes: 2 });
activity.end("immediate");
The component returns an object whose keys map to the different presentations: banner for the Lock Screen, and compactLeading, compactTrailing, minimal, and the expanded* slots for the Dynamic Island. The factory's start method returns a LiveActivity instance with update, end, and getInstances. The end call accepts a dismissal policy, where 'default' lets the system linger briefly, 'immediate' removes it at once, and after(date) keeps it up to four hours.
Driving Activities Remotely With Push
For activities that need to update while your app is backgrounded or fully closed, you set enablePushNotifications: true in the plugin config and lean on APNs. Each activity can hand you a push token, and you can also listen for push-to-start tokens that let your server create activities remotely without the app being open.
import {
addPushToStartTokenListener,
addUserInteractionListener,
} from "expo-widgets";
const activity = RideActivity.start({ driver: "Sam", etaMinutes: 5 });
activity.addPushTokenListener((token) => {
// Send this per-activity token to your backend so APNs can update it
sendTokenToServer(token);
});
const startSub = addPushToStartTokenListener((token) => {
// Register this token so the server can create activities remotely
registerPushToStart(token);
});
const tapSub = addUserInteractionListener((event) => {
// React to taps on your widgets
console.log("Widget interaction:", event);
});
The per-activity token from getPushToken or addPushTokenListener targets a single live activity, while the push-to-start token lets your backend spin up brand-new activities. Pair these with addUserInteractionListener to handle taps on a widget, and you have a complete loop: your server pushes state, the user taps, and your app responds, all without a line of Swift.
How It Stacks Up Against The Alternatives
It is worth knowing the neighbors, because the name "expo-widgets" is a little crowded and the right choice depends on your platform needs:
expo-widgets(official) is the only one where the widget UI is React. It is iOS-only, first-party, and the best developer experience for a JavaScript team, at the cost of being the newest and least battle-tested.@bittingz/expo-widgetsis a community module that supports iOS and Android, but you write the widget UI in native Swift and Kotlin files. More mature and broader in reach, with more native boilerplate.react-native-widget-extensionis an Expo config plugin for iOS widgets and Live Activities, but the UI is still authored in SwiftUI. A good fit if you are comfortable in Swift and want a thin bridge.react-native-android-widgetis the Android-only complement, where you build widgets in JSX that render to Android views. A natural pairing with the official module when you need both platforms.
The honest summary: if your team lives in TypeScript, wants iOS widgets and Live Activities, and would rather not learn SwiftUI, the official expo-widgets is the obvious pick, and you can reach for react-native-android-widget to cover Android.
Wrapping Up
expo-widgets takes one of the more intimidating corners of iOS development and folds it back into the workflow Expo developers already know. Instead of standing up an Xcode extension target and hand-writing SwiftUI, you write a React component with a 'widget' directive, describe it in your config plugin, and push data from JavaScript with updateSnapshot, updateTimeline, and the Live Activity factory. The result is real WidgetKit widgets, Lock-Screen accessories, and Dynamic Island activities that you maintain entirely from your JavaScript codebase.
The caveats are real and worth respecting: it is iOS only, it needs a development build rather than Expo Go, and it only stabilized in SDK 56, so expect some churn as the feature matures. But for a JS-first team that has been eyeing home-screen widgets and Live Activities from a distance, this is the first time the door has been genuinely open. If you are already on a recent Expo SDK, it is well worth an afternoon of experimentation.