Skip to content

Dynamic Widgets

Dynamic Widgets run their entry component on-device and can react to both app-supplied runtime props and current device state on iOS. Declare a Dynamic Widget in app.json with a stable id and an explicit entry, then default-export the Dynamic Widget from that file.

That means your Dynamic Widget can react to:

  • env.widgetFamily
  • env.colorScheme
  • env.locale, env.preferredLanguages, env.hourCycle, env.timeZone and the other locale fields
  • env.widgetRenderingMode
  • env.showsWidgetContainerBackground
  • env.configuration when you also add widget parameters

When you change app.json, run Expo Prebuild or Voltra Apply so the updated widget configuration is available on device. If you change only the widget JS, reopen the app in development and the widget updates automatically.

Dynamic Widgets require @use-voltra/metro in the app project. Install it alongside the iOS packages:

Terminal window
npm install @use-voltra/metro

Wrap the app’s existing Metro config with withVoltra:

metro.config.js
const { getDefaultConfig } = require('expo/metro-config')
const { withVoltra } = require('@use-voltra/metro')
const config = getDefaultConfig(__dirname)
module.exports = withVoltra(config)
  1. Add an iOS widget declaration to app.json with an id, an entry, and any widget metadata you need.
  2. Default-export the widget function or component from the module named by entry.
  3. Use initialStatePath if you want a pre-rendered first view.
  4. Re-run Expo Prebuild or Voltra Apply after updating app.json.
  5. Keep iOS and Android widget declarations separate; the same id can exist on both platforms because each platform is configured separately.
import { Voltra, type WidgetEnvironment } from '@use-voltra/ios'
type WeatherDynamicWidgetProps = {
headline?: string
unreadCount?: number
}
export default function WeatherDynamicWidget(
props: WeatherDynamicWidgetProps = {},
env: WidgetEnvironment = {} as WidgetEnvironment
) {
const headline = props.headline ?? 'Weather update'
const unreadCount = props.unreadCount ?? 0
// Format with the user's locale, 12/24-hour setting and time zone.
const renderedAt = (env.date ? new Date(env.date) : new Date()).toLocaleTimeString(env.locale, {
hour: '2-digit',
minute: '2-digit',
hourCycle: env.hourCycle,
timeZone: env.timeZone,
})
return (
<Voltra.VStack style={{ padding: 16, backgroundColor: '#111827' }}>
<Voltra.Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>
{headline}
</Voltra.Text>
<Voltra.Text style={{ color: '#34D399', marginTop: 6 }}>{unreadCount} unread</Voltra.Text>
<Voltra.Text style={{ color: '#9CA3AF', marginTop: 6 }}>
Family: {env.widgetFamily}
</Voltra.Text>
<Voltra.Text style={{ color: '#9CA3AF' }}>
Scheme: {env.colorScheme ?? 'light'}
</Voltra.Text>
<Voltra.Text style={{ color: '#9CA3AF' }}>
Rendered: {renderedAt}
</Voltra.Text>
</Voltra.VStack>
)
}

Example plugin config:

{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.com.example.app",
"widgets": [
{
"id": "weather_widget",
"entry": "./widgets/ios/weather-widget.tsx",
"displayName": "Weather Widget",
"description": "A Dynamic Widget that reacts to live device state",
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/ios/weather-widget.tsx"
}
]
}
]
]
}
}

groupIdentifier is required when using runtime props because the app and WidgetKit extension exchange the latest props through the shared App Group. Environment-only Dynamic Widgets can omit it.

After changing the plugin configuration, rebuild the native iOS app. You also need a native rebuild after upgrading to a version of @use-voltra/ios-client that introduces a new native API.

Call updateDynamicWidget from your app. The object is serialized, persisted, and passed as the first argument to the entry component on each subsequent render. The update re-renders every installed instance with the matching Dynamic Widget id.

import { updateDynamicWidget } from '@use-voltra/ios-client'
await updateDynamicWidget('weather_widget', {
headline: 'Rain arriving soon',
unreadCount: 2,
})

Dynamic Widget props must be JSON-serializable. You can use strings, numbers, booleans, null, arrays, and nested objects. Functions, undefined, Date, BigInt, and cyclic references are not supported.

Before the first call to updateDynamicWidget, the entry component receives {}. The latest props object is persisted by Dynamic Widget id, survives app process restarts, and is reused until a later call replaces it or the app’s data is cleared.

Props do not have to come from the app. Add a serverUpdate alongside entry and the widget fetches a JSON object on a schedule and renders it, without the app running:

{
"id": "portfolio",
"entry": "./widgets/ios/portfolio.tsx",
"serverUpdate": {
"url": "https://api.example.com/widgets/portfolio",
"intervalMinutes": 30
}
}

The response object becomes the same first argument updateDynamicWidget passes, so the entry component does not change. Because the server returns data rather than a rendered payload, the backend can be written in any language.

updateDynamicWidget keeps working on a server-driven widget — the next fetch simply overwrites what you wrote. See Server-driven widgets for the response contract, the env.serverUpdate fields, and how to take a widget over.

Runtime props and configuration are separate

Section titled “Runtime props and configuration are separate”

Dynamic Widget props are app-owned state passed as the entry component’s first argument. Configuration values are declared through appIntent.parameters, edited by the user in the native iOS Edit Widget sheet, and read from env.configuration. Updating runtime props does not replace configuration.

If you want user-editable values, add appIntent too. See Configurable Widgets.

Pass env.locale to every Intl and toLocale* call, and pick a translation with resolveLocale(env, messages). See Localization for every locale field, how to declare the languages your app supports, and how to let the app choose the widget language.

  • There is no export field in app.json for Dynamic Widgets.
  • The default-exported function or component name does not need to match the widget id.
  • Use a real device to verify release rendering.
  • initialStatePath gives the widget a pre-rendered first view.
  • To use SwiftUI modifiers that style does not cover, such as widgetURL or containerBackground, see Native modifiers.