---
url: /getting-started/introduction.md
---
# Introduction
Voltra is a library that brings new "platforms" to React Native. Up until now, creating features like iOS Live Activities, Dynamic Island layouts, or Android Home Screen Widgets required writing native code in Swift or Kotlin.
Voltra changes this by providing a JavaScript-based API and JSX components that get automatically converted to native primitives (SwiftUI on iOS, Jetpack Compose Glance on Android).
## Why Voltra?
- **React Native Everywhere:** Extend your React Native app with native platform features using the same JSX syntax you already know.
- **No Native Code Required:** Build complex widget layouts and live activities without touching Xcode or Android Studio for UI code.
- **Platform-native JSX:** Use platform-specific primitives that map directly to SwiftUI on iOS and Glance on Android.
- **Real-time Updates:** Stream updates to your activities and widgets via push notifications (APNS/FCM) from any JavaScript runtime.
## How it works
Voltra works by serializing your JSX components into a lightweight JSON format that the native platform extensions can interpret. This enables features like hot reloading during development and server-side rendering for push updates.
Here's how simple it is to create a live activity:
```tsx
import { Voltra } from '@use-voltra/ios'
import { startLiveActivity } from '@use-voltra/ios-client'
const activityUI = (
Driver en routeBuilding A · Lobby pickupContact driver
)
// Start the live activity
await startLiveActivity({
lockScreen: activityUI,
})
```
If you prefer using the hook API (`useLiveActivity`), you'll get live reloads for live activities, with changes appearing in milliseconds without manual restarts.
## Server-side updates via push notifications
Voltra also supports server-side updates through push notifications. You can use Voltra's server-side rendering to convert JSX into JSON payloads that you send to devices via Apple's Push Notification Service (APNS) or Firebase Cloud Messaging (FCM). This enables real-time updates without keeping your app running.
The same components you use in your app work on the server:
```tsx
import { Voltra, renderLiveActivityToString } from '@use-voltra/ios-server'
// Render JSX to JSON payload on your server
const payload = renderLiveActivityToString({
lockScreen: (
Driver arrivedReady for pickup
),
})
```
Ready to get started? Head over to the [Installation](/getting-started/installation.md) guide, or explore platform-specific guides for [iOS](/ios/introduction.md) and [Android](/android/introduction.md).
---
url: /ios/introduction.md
---
# Introduction
Adding live activities to your iOS app has traditionally been a time-consuming and complex process. JavaScript developers need to learn Xcode, master SwiftUI, understand how to start live activities, and figure out how to manage them throughout their lifecycle. This creates barriers between your app development and these dynamic features.
## Voltra + JSX = Live Activity
Voltra changes all of that by providing a JavaScript-based API you can use to display live activities in your app. Instead of writing SwiftUI code, you write JSX using Voltra components that get automatically converted to SwiftUI and displayed just like native code.
Here's how simple it is to create a live activity:
```tsx
import { Voltra } from '@use-voltra/ios'
import { startLiveActivity } from '@use-voltra/ios-client'
const activityUI = (
Driver en routeBuilding A · Lobby pickupContact driver
)
// Start the live activity
await startLiveActivity({
lockScreen: activityUI,
})
```
If you prefer using the hook API (`useLiveActivity`), you'll get live reloads for live activities, with changes appearing in milliseconds without manual restarts.
## Server-side updates via push notifications
Voltra also supports server-side updates through push notifications. You can use Voltra's server-side rendering to convert JSX into JSON payloads that you send to devices via Apple's Push Notification Service (APNS). This enables real-time updates without keeping your app running.
The same components you use in your app work on the server:
```tsx
import { Voltra, renderLiveActivityToString } from '@use-voltra/ios-server'
// Render JSX to JSON payload on your server
const payload = renderLiveActivityToString({
lockScreen: (
Driver arrivedReady for pickup
),
})
```
You're ready to dive into the [setup guide](/ios/setup.md) and get started with live activities in your app.
---
url: /android/introduction.md
---
# Android Introduction
:::warning Experimental Support
Android support is **experimental**. Although it should work just fine, the API may change. Stay vigilant.
:::
Voltra brings the power of JSX-based UI to Android Home Screen widgets. Using Jetpack Compose Glance under the hood, Voltra allows you to define Android widgets using a set of primitives that map directly to Glance components.
## Widgets on Android
Android widgets have different layout and styling rules compared to iOS Live Activities. While iOS uses SwiftUI-based primitives (VStack, HStack, etc.), Android uses Jetpack Compose Glance primitives (Column, Row, Box).
Voltra abstracts these differences where possible, but provides platform-specific namespaces to ensure your UI looks and behaves correctly on each platform.
Voltra also exposes Android-specific semantic dynamic colors through `AndroidDynamicColors`, which lets widgets follow the current Material palette without requiring a JavaScript re-render. See [Dynamic Colors](/android/development/dynamic-colors.md).
Voltra also supports Android ongoing notifications for app-driven, persistent status updates. See [Managing Android Ongoing Notifications](/android/development/managing-ongoing-notifications.md).
### Simple Android Widget
```tsx
import { VoltraAndroid } from '@use-voltra/android'
const MyWidget = () => (
Android Widget
Powered by Voltra & Glance
)
```
## Key Differences
- **Primitives:** Use `VoltraAndroid.Column`, `VoltraAndroid.Row`, and `VoltraAndroid.Box` instead of stacks.
- **Alignment:** Android uses specific alignment props like `verticalAlignment` and `horizontalAlignment`.
- **Sizing:** Use `"100%"` for full size or `"auto"` for wrapping content.
## Testing and Previews
You can preview your Android widgets directly in your app using the `VoltraWidgetPreview` component. This allows for fast iteration without needing to constantly check the home screen.
Learn more in the [Testing and Previews guide](/android/development/testing-and-previews.md).
## Next Steps
Check out the [Setup guide](/android/setup.md) to set up Voltra for Android.
For notification-based experiences, see [Managing Android Ongoing Notifications](/android/development/managing-ongoing-notifications.md).
---
url: /android/api/plugin-configuration.md
---
# Plugin Configuration (Android)
The Voltra Expo config plugin accepts Android-specific configuration options in your `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"enableNotifications": true,
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Shows current weather conditions",
"targetCellWidth": 2,
"targetCellHeight": 2,
"initialStatePath": "./widgets/weather-initial.tsx",
"previewImage": "./assets/widgets/weather-preview.png"
}
]
}
]
]
}
}
```
## Android-Specific Configuration
### `enableNotifications` (optional)
Enables Android notification-related manifest plumbing used by Voltra features such as ongoing notifications.
When enabled, the config plugin adds:
- `android.permission.POST_NOTIFICATIONS`
- `android.permission.POST_PROMOTED_NOTIFICATIONS`
- `voltra.VoltraOngoingNotificationDismissedReceiver`
This does not grant runtime notification permission automatically. Your app still needs to request notification permission on Android 13 and above.
For setup and usage examples, see [Managing Android Ongoing Notifications](/android/development/managing-ongoing-notifications.md).
### `widgets` (optional)
Array of widget configurations for Home Screen widgets. Each widget will be available in the Android widget picker.
**Widget Configuration Properties:**
- `id`: Unique identifier for the widget (alphanumeric with underscores only)
- `displayName`: Name shown in the widget picker (plain string, or per-locale map; same rules as iOS `widgets[].displayName`)
- `description`: Description shown in the widget picker (same rules as `displayName`)
- `targetCellWidth`: Target widget width in grid cells (1-5, required)
- `targetCellHeight`: Target widget height in grid cells (1-5, required)
- `minCellWidth`: (optional) Minimum width in grid cells (defaults to targetCellWidth)
- `minCellHeight`: (optional) Minimum height in grid cells (defaults to targetCellHeight)
- `minWidth`: (optional) Minimum width in dp (overrides minCellWidth calculation)
- `minHeight`: (optional) Minimum height in dp (overrides minCellHeight calculation)
- `resizeMode`: (optional) Widget resize behavior (`"none"` | `"horizontal"` | `"vertical"` | `"horizontal|vertical"`, default: `"horizontal|vertical"`)
- `widgetCategory`: (optional) Widget category (`"home_screen"` | `"keyguard"` | `"home_screen|keyguard"`, default: `"home_screen"`)
- `initialStatePath`: (optional) Path to a file that exports initial widget state, or a locale map of paths for localized build-time pre-rendering (see [Widget Pre-rendering](/android/development/widget-pre-rendering.md))
- `previewImage`: (optional) Path to preview image for widget picker (PNG/JPG/WebP)
- `previewLayout`: (optional) Path to custom XML layout for widget picker preview (Android 12+)
- `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](/android/development/server-driven-widgets.md) for full details.
- `url`: The Voltra SSR endpoint URL
- `intervalMinutes`: Update interval in minutes (default: `15`, minimum 15 per WorkManager)
- `refresh`: Show a native refresh button (default: `false`)
### Localizing `displayName` and `description`
Use a locale map when the widget picker label should be translated:
```json
{
"widgets": [
{
"id": "weather",
"displayName": {
"en": "Weather",
"pl": "Pogoda",
"zh-Hans": "天气"
},
"description": {
"en": "Current weather conditions",
"pl": "Aktualne warunki pogodowe",
"zh-Hans": "当前天气状况"
},
"targetCellWidth": 2,
"targetCellHeight": 2
}
]
}
```
Use BCP-47-style locale tags such as `en`, `en-US`, `pt-BR`, or `zh-Hans`.
Fallback behavior:
- Voltra first tries the device locale.
- If there is no exact match, it falls back to the language-only match.
- If there is still no match, it prefers an English locale such as `en` or `en-US`.
- If no English entry exists, it uses the first configured locale.
## Widget Sizing
### Grid Cells vs Density-Independent Pixels (dp)
Android uses grid cells to define widget sizes. By default, the formula is:
- **minWidth/minHeight (dp) = (cellCount × 70) - 30**
**Example:**
- 2 cells = (2 × 70) - 30 = **110 dp**
- 4 cells = (4 × 70) - 30 = **250 dp**
You can override this with explicit `minWidth` and `minHeight` in dp.
### Standard Dimensions
| Family | Cells | Default DP | Typical Use |
| ----------- | ----- | ---------- | ----------------- |
| Small | 2×1 | 110 × 40 | Quick glance info |
| Medium | 2×2 | 110 × 110 | Main widget size |
| Large | 4×2 | 250 × 110 | Rich content |
| Extra Large | 4×4 | 250 × 250 | Complex layouts |
## Widget Picker Previews
When users add a widget to their home screen, Android displays a preview in the widget picker. Voltra supports three preview methods, with automatic fallback:
### Preview Priority Chain
1. **`previewLayout`** (Android 12+) - Custom XML layout for scalable preview
2. **`previewImage`** (All versions) - Static image or auto-generated layout
3. **Default** - System placeholder layout
### Using `previewImage`
Static preview image for all Android versions:
```json
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"targetCellWidth": 2,
"targetCellHeight": 2,
"previewImage": "./assets/widgets/weather-preview.png"
}
]
}
```
When only `previewImage` is specified, Voltra automatically generates a layout that displays the image with proper scaling.
### Using `previewLayout`
Custom XML layout for scalable previews (Android 12+):
```json
{
"widgets": [
{
"id": "todos",
"displayName": "Todo Widget",
"targetCellWidth": 2,
"targetCellHeight": 2,
"previewLayout": "./assets/widgets/todos-preview.xml"
}
]
}
```
**Example `todos-preview.xml`:**
```xml
```
The preview layout is rendered at the widget's target size and displayed in the widget picker.
### Combined Preview Setup
For best results across Android versions:
```json
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"targetCellWidth": 2,
"targetCellHeight": 2,
"previewImage": "./assets/widgets/weather-preview.png",
"previewLayout": "./assets/widgets/weather-preview.xml",
"initialStatePath": "./widgets/weather-initial.tsx"
}
]
}
```
This configuration:
- Uses `previewLayout` on Android 12+ (scalable, accurate preview)
- Falls back to `previewImage` on Android 11 and earlier
- Shows actual widget content on home screen via `initialStatePath` (when available)
## Widget Pre-rendering
Use `initialStatePath` to provide pre-rendered widget state:
```json
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"targetCellWidth": 2,
"targetCellHeight": 2,
"initialStatePath": "./widgets/weather-initial.tsx"
}
]
}
```
When the app is built, Voltra pre-renders the widget at the specified path and bundles it as `voltra_initial_states.json`. The widget displays this content immediately when first added to the home screen, before any dynamic updates.
See [Widget Pre-rendering](/android/development/widget-pre-rendering.md) for details on creating initial state files.
## Example Configuration
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"enableNotifications": true,
"widgets": [
{
"id": "voltra",
"displayName": "Voltra Widget",
"description": "Voltra logo widget",
"minCellWidth": 2,
"minCellHeight": 2,
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen",
"initialStatePath": "./widgets/android-voltra-widget-initial.tsx",
"previewImage": "./assets/voltra-icon.jpg"
},
{
"id": "interactive_todos",
"displayName": "Interactive Todos",
"description": "Quick todo list widget",
"targetCellWidth": 2,
"targetCellHeight": 2,
"previewLayout": "./assets/widgets/todos-preview.xml"
}
]
}
]
]
}
}
```
---
url: /android/charts.md
---
# Charts (Android)
Use charts in Android widgets to show trends, comparisons, progress, or composition at a glance. You can mix bars, lines, areas, points, rules, and sectors in a single chart.
:::info
Charts are rendered to a bitmap using the Android Canvas API and displayed as a Glance `Image`. This approach is required because Jetpack Glance has no native charting components.
:::
:::warning
Mark components (`BarMark`, `LineMark`, and the other mark types) must be direct children of ``. Do not wrap them in a custom component.
:::
## Basic Usage
Wrap one or more mark components inside ``:
```tsx
```
## Data Types
All marks except `RuleMark` take a `data` prop. There are two data shapes depending on the mark type:
```typescript
// BarMark, LineMark, AreaMark, PointMark
type ChartDataPoint = {
x: string | number // Categorical ("Jan") or numeric (42)
y: number
series?: string // Optional — groups data for multi-series charts
}
// SectorMark
type SectorDataPoint = {
value: number // Proportional angular value
category: string // Sector label
}
```
## Marks
### BarMark
Use bars when people need to compare values across categories.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Fill color.
- `cornerRadius` (number, optional): Rounded bar corners.
- `width` (number, optional): Fixed bar width.
- `stacking` (string, optional): `"grouped"` for side-by-side bars in a multi-series chart.
```tsx
```
***
### LineMark
Use a line when the shape of change matters more than individual columns.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Line color.
- `lineWidth` (number, optional): Stroke width.
- `interpolation` (string, optional): `"linear"`, `"monotone"`, `"catmullRom"`, `"cardinal"`, `"stepStart"`, `"stepCenter"`, or `"stepEnd"`.
```tsx
```
***
### AreaMark
Use an area chart when you want the overall volume or rise/fall pattern to read quickly.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Fill color.
- `interpolation` (string, optional): Same options as `LineMark`.
```tsx
```
***
### PointMark
Use points for sparse measurements, scatter plots, or to emphasize exact observations.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Point color.
- `symbolSize` (number, optional): Point size.
```tsx
```
***
### RuleMark
A horizontal or vertical reference line. Unlike other marks, RuleMark has no `data` array.
**Parameters:**
- `yValue` (number, optional): Draw a horizontal line at this y value.
- `xValue` (string | number, optional): Draw a vertical line at this x value.
- `color` (string, optional): Line color.
- `lineWidth` (number, optional): Stroke width.
```tsx
```
***
### SectorMark
Pie and donut charts.
**Parameters:**
- `data` (SectorDataPoint\[], required): Sector data with `value` and `category`.
- `color` (string, optional): Fill color (overrides automatic coloring).
- `innerRadius` (number, optional): `0` = pie chart, any value above `0` = donut chart. Values ≤ 1 are treated as a ratio of the max radius; values > 1 are treated as absolute dp.
- `outerRadius` (number, optional): Same behavior as `innerRadius`.
- `angularInset` (number, optional): Gap between sectors in degrees.
```tsx
// Pie chart
// Donut chart
```
## Chart Props
The `` container accepts these props in addition to the standard `style` prop:
| Prop | Type | Description |
| ---------------------- | -------------------------------------- | ------------------------------ |
| `xAxisVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the x-axis |
| `xAxisGridStyle` | `{ visible?: boolean }` | Show or hide x-axis grid lines |
| `yAxisVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the y-axis |
| `yAxisGridStyle` | `{ visible?: boolean }` | Show or hide y-axis grid lines |
| `foregroundStyleScale` | `Record` | Map series names to colors |
## Grid Lines
Hide grid lines when you want the chart to feel more compact:
```tsx
```
## Multi-Series Charts
Add a `series` field to your data points when you want multiple datasets in the same chart. Use `foregroundStyleScale` to keep those series colors consistent:
```tsx
```
Without `foregroundStyleScale`, colors are chosen automatically.
For side-by-side bars, set `stacking="grouped"`:
```tsx
```
## Combining Marks
Mix mark types when one chart needs both context and emphasis, such as bars for totals plus a rule for a target:
```tsx
```
## Sparkline / Minimal Style
Hide axes for a clean, compact visualization:
```tsx
```
## Sizing
Chart dimensions are read from the `style` prop:
- **Fixed size**: `style={{ width: 300, height: 200 }}`
- **Fill parent**: `style={{ width: '100%', height: '100%' }}`
If no width or height is specified, the chart defaults to 300×200 dp.
## Platform Notes
- `legendVisibility` is not currently available on Android charts.
- Point markers are circular on Android.
- `innerRadius` and `outerRadius` on `SectorMark` accept either a ratio (`0` to `1`) or a larger fixed value.
---
url: /android/components/interactive.md
---
# Interactive Controls (Android)
User interface controls that respond to user interaction on Android widgets.
### Button
Standard button component. On Android, all buttons always open the application when clicked. You can provide a `deepLinkUrl` to open a specific screen.
**Parameters:**
- `enabled` (boolean, optional): Whether the button is enabled.
- `deepLinkUrl` (string, optional): URL to open when the button is clicked. If not provided, the app will open to its main activity.
Voltra also provides specialized button variants:
#### FilledButton
- `text` (string): Button label.
- `enabled` (boolean, optional).
- `deepLinkUrl` (string, optional).
- `icon` (object, optional): `{ assetName: string }`.
- `backgroundColor` (string, optional).
- `contentColor` (string, optional).
- `maxLines` (number, optional).
#### OutlineButton
- `text` (string): Button label.
- `enabled` (boolean, optional).
- `deepLinkUrl` (string, optional).
- `icon` (object, optional): `{ assetName: string }`.
- `contentColor` (string, optional).
- `maxLines` (number, optional).
#### CircleIconButton & SquareIconButton
- `enabled` (boolean, optional).
- `deepLinkUrl` (string, optional).
- `icon` (object, optional): `{ assetName: string, base64: string }`.
- `contentDescription` (string, optional).
- `backgroundColor` (string, optional).
- `contentColor` (string, optional).
***
### Clickable Components
Most components support being clickable by setting the `pressable` prop (short name `prs` in raw elements) in their props.
**Parameters:**
- `pressable` (boolean): Set to `true` to make the component respond to clicks.
- `deepLinkUrl` (string, optional): URL to open when clicked.
***
### Switch
A toggle switch component. On Android, toggles always open the application when clicked.
**Parameters:**
- `id` (string): Unique identifier for interaction events.
- `checked` (boolean, optional): Current state of the switch.
- `deepLinkUrl` (string, optional): URL to open when clicked.
- `text` (string, optional): Label displayed next to the switch.
- `thumbCheckedColor` (string, optional).
- `thumbUncheckedColor` (string, optional).
- `trackCheckedColor` (string, optional).
- `trackUncheckedColor` (string, optional).
- `maxLines` (number, optional): Maximum lines for the label.
***
### CheckBox
Standard checkbox component. On Android, checkboxes always open the application when clicked.
**Parameters:**
- `id` (string): Unique identifier for interaction events.
- `checked` (boolean, optional).
- `deepLinkUrl` (string, optional).
- `text` (string, optional).
- `checkedColor` (string, optional).
- `uncheckedColor` (string, optional).
- `maxLines` (number, optional).
***
### RadioButton
Standard radio button component. On Android, radio buttons always open the application when clicked.
**Parameters:**
- `id` (string): Unique identifier for interaction events.
- `checked` (boolean, optional).
- `enabled` (boolean, optional).
- `deepLinkUrl` (string, optional).
- `text` (string, optional).
- `checkedColor` (string, optional).
- `uncheckedColor` (string, optional).
- `maxLines` (number, optional).
---
url: /android/components/layout.md
---
# Layout & Containers (Android)
Components that arrange other elements or provide structural grouping using Jetpack Compose Glance primitives. See [Styling](/android/development/styling.md) for details on layout and spacing properties.
### Column
A vertical container that arranges its children in a column.
**Parameters:**
- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`.
- `verticalAlignment` (string, optional): `"top"`, `"center-vertically"`, `"bottom"`.
***
### Row
A horizontal container that arranges its children in a row.
**Parameters:**
- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`.
- `verticalAlignment` (string, optional): `"top"`, `"center-vertically"`, `"bottom"`.
***
### Box
A container that stacks its children on top of each other.
**Parameters:**
- `contentAlignment` (string, optional): Combined alignment. Supports `"top-start"`, `"top-center"`, `"top-end"`, `"center-start"`, `"center"`, `"center-end"`, `"bottom-start"`, `"bottom-center"`, `"bottom-end"`.
***
### Scaffold
A top-level container that provides a standard layout structure for widgets.
**Parameters:**
- `backgroundColor` (string, optional): Background color for the scaffold.
- `horizontalPadding` (number, optional): Horizontal padding in dp.
***
### TitleBar
A component that displays a title bar with a required leading icon.
**Parameters:**
- `title` (string): Title text to display.
- `startIcon` (object, required): `{ assetName: string }` or `{ base64: string }`.
- `textColor` (string, optional).
- `iconColor` (string, optional).
- `fontFamily` (string, optional): `"monospace"`, `"serif"`, `"sans-serif"`, or `"cursive"`.
***
### Spacer
A component that provides fixed spacing between elements.
**Parameters:**
- `size` (number): Size of the spacer in dp.
***
### LazyColumn
A scrollable vertical list that only renders visible items.
**Parameters:**
- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`.
***
### LazyVerticalGrid
A scrollable grid of items.
**Parameters:**
- `columns` (number | `"adaptive"`): Number of columns or `"adaptive"` for an adaptive grid.
- `minSize` (number, optional): Minimum size (in dp) for items in adaptive grid mode.
- `horizontalAlignment` (string, optional): `"start"`, `"center-horizontally"`, `"end"`.
- `verticalAlignment` (string, optional): `"top"`, `"center"`, `"bottom"`.
---
url: /android/components/status.md
---
# Data Visualization & Status (Android)
Components for displaying data and status information on Android widgets.
### LinearProgressIndicator
A horizontal progress bar.
**Parameters:**
- `progress` (number, optional): Current progress value (0.0 to 1.0). If omitted, the indicator will be indeterminate.
- `color` (string, optional): Color for the progress indicator.
- `backgroundColor` (string, optional): Color for the background track.
***
### CircularProgressIndicator
A circular progress indicator.
**Parameters:**
- `color` (string, optional): Color for the progress indicator.
:::warning Indeterminate Only
Due to Jetpack Compose Glance limitations, the `CircularProgressIndicator` on Android renders in **indeterminate** mode.
:::
---
url: /android/components/visual.md
---
# Visual Elements & Typography (Android)
Static or decorative elements used to display content on Android widgets. See [Styling](/android/development/styling.md) for details on supported style properties.
### Text
Displays text content.
**Parameters:**
- `maxLines` (number, optional): Maximum number of lines to display.
- `renderAsBitmap` (boolean, optional): Renders text as a bitmap image to enable [custom fonts](/android/development/custom-fonts.md). Requires `fontFamily` in the style prop.
***
### Image
Displays bitmap images from the asset catalog, preloaded runtime cache, or base64 encoded data. SVGs should be rasterized through the image preloading API and referenced by `assetName`.
**Parameters:**
- `source` (object, optional): Image source object.
- `assetName` (string): Reference to a pre-bundled image (drawable resource) or a [preloaded image](/android/development/image-preloading.md).
- `base64` (string): Base64 encoded image data.
- `resizeMode` (string, optional): `"cover"`, `"contain"`, `"stretch"`, `"repeat"`, or `"center"`.
- `contentDescription` (string, optional): Accessibility description for the image.
- `contentScale` (string, optional): Glance-specific scaling mode: `"crop"`, `"cover"`, `"fit"`, `"contain"`, `"fill-bounds"`, or `"stretch"`.
- `alpha` (number, optional): Opacity value from 0.0 to 1.0.
- `colorFilter` (string, optional): Color tint filter for the image.
- `tintColor` (string, optional): Legacy alias for `colorFilter`.
- `fallback` (ReactNode, optional): Custom content rendered when the image is missing.
**Styling the fallback:**
To add a background color when an image is missing, use `backgroundColor` in the `style` prop:
```jsx
```
:::tip Image Preloading
For dynamic images from remote URLs, use the [Image Preloading](/android/development/image-preloading.md) API to cache them locally for use in widgets.
:::
---
url: /android/development/custom-fonts.md
---
# Custom Fonts
Android Glance only supports a handful of built-in font families (`monospace`, `serif`, `sans-serif`, `cursive`). Voltra works around this by rendering text as a bitmap with a custom `Typeface` loaded from `assets/fonts/`.
## Setup
### 1. Add font files to the plugin config
List your font paths in the top-level `fonts` array. These can be local files or packages from `@expo-google-fonts`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"fonts": [
"node_modules/@expo-google-fonts/pacifico/400Regular/Pacifico_400Regular.ttf",
"./assets/fonts/MyCustomFont.ttf"
],
"widgets": []
}
]
]
}
}
```
### 2. Run prebuild
```bash
npx expo prebuild
```
The plugin copies each font file to `android/app/src/main/assets/fonts/` automatically.
### 3. Use `renderAsBitmap` on Text
```tsx
import { VoltraAndroid } from '@use-voltra/android'
Hello Voltra!
```
The `fontFamily` value should match the font filename **without the extension**.
## How it works
When `renderAsBitmap` is set and `fontFamily` is provided in the style:
1. The font is loaded via `Typeface.createFromAsset()` (cached with an LRU cache)
2. Text is drawn to an Android `Canvas` bitmap using `StaticLayout`
3. The bitmap is displayed as a Glance `Image` with fixed dp dimensions
This means the text is rasterized — it won't respond to system font size settings. Use it only when a custom typeface is needed.
## Supported style properties
When rendering as bitmap, the following text style properties are supported:
| Property | Description |
| -------------------- | ------------------------------------------ |
| `fontSize` | Font size in sp (scaled to device density) |
| `fontFamily` | Font filename without extension |
| `fontWeight` | `"normal"` or `"bold"` |
| `color` | Text color |
| `textAlign` | `"left"`, `"center"`, `"right"` |
| `textDecorationLine` | `"underline"`, `"line-through"` |
| `letterSpacing` | Letter spacing value |
| `lineHeight` | Line spacing |
## Built-in font families
For built-in families you don't need `renderAsBitmap` — use `fontFamily` in style directly:
- `monospace`
- `serif`
- `sans-serif`
- `cursive`
These are passed through to Glance's native `FontFamily` API.
---
url: /android/development/developing-widgets.md
---
# Developing Android Widgets
Voltra allows you to build Android Home Screen widgets using JSX and Jetpack Compose Glance primitives.
## Glance Primitives
On Android, you use `VoltraAndroid` components which map to Glance primitives:
- **Column:** Vertical layout
- **Row:** Horizontal layout
- **Box:** Stacked layout
- **Spacer:** Flexible spacing
- **Text:** Displaying text
- **Image:** Displaying images
- **Scaffold:** Top-level container
### Example Widget
```tsx
import { VoltraAndroid } from '@use-voltra/android'
const WeatherWidget = ({ temperature, condition }) => (
{temperature}°C
{condition}
)
```
## Update API
To update a widget's content, use the `updateAndroidWidget` function from `@use-voltra/android-client`:
```typescript
import { updateAndroidWidget } from '@use-voltra/android-client'
await updateAndroidWidget('weather_widget', )
```
## Layout Constraints
Unlike standard React Native or iOS Stacks, Android Glance layouts are more restrictive:
- **Width/Height:** Use fixed numbers (dp), `"100%"` to fill available space, or `"auto"` to wrap content.
- **Modifiers:** Most styling is handled via the `style` prop, which maps to Glance `Modifier`s. See [Styling](/android/development/styling.md) for full details.
- **Alignment:** Use `verticalAlignment` and `horizontalAlignment` props on `Column` and `Row`.
## Advanced Features
- **[Querying Active Widgets](/android/development/querying-active-widgets.md):** Detect active widget instances and their sizes.
- **[Testing and Previews](/android/development/testing-and-previews.md):** Preview layouts within your app.
- **[Widget Picker Previews](/android/api/plugin-configuration.md#widget-picker-previews):** Configure how your widget appears in the Android widget picker.
- **[Image Preloading](/android/development/image-preloading.md):** Cache remote images for use in widgets.
- **[Widget Pre-rendering](/android/development/widget-pre-rendering.md):** Provide initial state for widgets before the app first runs.
## Widget Picker Previews
When users browse the widget picker to add your widget to their home screen, they see a preview. You can customize this preview using:
- **`previewImage`:** Static image (PNG/JPG/WebP) that shows in the picker on all Android versions
- **`previewLayout`:** Custom XML layout that renders a scalable preview on Android 12+
See [Plugin Configuration - Widget Picker Previews](/android/api/plugin-configuration.md#widget-picker-previews) for configuration details and examples.
---
url: /android/development/dynamic-colors.md
---
# Dynamic colors
Voltra supports Android dynamic colors through semantic tokens exposed from `@use-voltra/android`.
These colors follow the current Android Material palette, so widgets can pick up wallpaper and theme changes without waiting for JavaScript to run again.
## Importing dynamic colors
```tsx
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
```
`AndroidDynamicColors` includes these roles:
- `primary`
- `onPrimary`
- `primaryContainer`
- `onPrimaryContainer`
- `secondary`
- `onSecondary`
- `secondaryContainer`
- `onSecondaryContainer`
- `tertiary`
- `onTertiary`
- `tertiaryContainer`
- `onTertiaryContainer`
- `error`
- `errorContainer`
- `onError`
- `onErrorContainer`
- `background`
- `onBackground`
- `surface`
- `onSurface`
- `surfaceVariant`
- `onSurfaceVariant`
- `outline`
- `inverseOnSurface`
- `inverseSurface`
- `inversePrimary`
- `widgetBackground`
## Example
```tsx
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
export function WeatherWidget() {
return (
21°
)
}
```
## Where you can use them
You can use `AndroidDynamicColors.*` anywhere Android accepts a color value, including:
- `style.backgroundColor`
- `style.color`
- `Image.colorFilter`
- button `backgroundColor` and `contentColor`
- `TitleBar.textColor` and `TitleBar.iconColor`
- switch, checkbox, and radio button colors
- progress indicator colors
- chart mark colors
## Server-driven widgets
Dynamic color tokens work in server-rendered Android widgets too.
```tsx
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
const content = (
Server-rendered widget
)
```
The same `AndroidDynamicColors.*` values work whether the widget is rendered in-app or returned from your server.
## Migration notes
Voltra no longer uses the old Android dynamic palette snapshot approach.
- Use `AndroidDynamicColors.*` for Android widgets that should react to system palette changes.
- Keep using literal colors when you want a fixed color.
- There is no `useAndroidDynamicColorPalette()` or `getAndroidDynamicColorPalette()` API anymore.
---
url: /android/development/dynamic-widgets.md
---
# Dynamic Widgets
:::warning Experimental Feature
Dynamic widgets are experimental. Please [report any issues](https://github.com/callstackincubator/voltra/issues) you find.
:::
Dynamic Widgets let your widget react to the current device state on Android. Declare them in `app.json` with a stable `id` and an explicit `entry`, then default-export the widget from that file.
Your widget can react to:
- `env.widgetFamily`
- `env.colorScheme`
- `env.locale`
- `env.configuration`
- `AndroidDynamicColors` tokens, which resolve to the current Material You palette natively
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.
## How to use it
1. Add an Android 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 Android and iOS widget declarations separate; the same `id` can exist on both platforms because each platform is configured separately.
```tsx
import { AndroidDynamicColors, VoltraAndroid, type WidgetEnvironment } from '@use-voltra/android'
export default function WeatherWidget(_props: object, env: WidgetEnvironment = {} as WidgetEnvironment) {
const renderedAt = env.date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})
return (
Weather Widget
Size: {env.widgetFamily}
Scheme: {env.colorScheme ?? 'light'}
Rendered: {renderedAt}
)
}
```
Example plugin config:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"widgets": [
{
"id": "weather_widget",
"entry": "./widgets/android/weather-widget.tsx",
"displayName": "Weather Widget",
"description": "A Dynamic Widget that reacts to live device state",
"targetCellWidth": 2,
"targetCellHeight": 2,
"initialStatePath": "./widgets/android/weather-widget.tsx"
}
]
}
]
]
}
}
```
If you need user-controlled values, add `appIntent.parameters` and update them in-app with `setWidgetConfiguration(widgetId, key, value)`.
## Notes
- 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.
---
url: /android/development/image-preloading.md
---
# Image Preloading (Android)
Android widgets have limitations when it comes to displaying remote images directly. The image preloading API allows you to download images to the app's cache directory, making them available to your widgets via a local `FileProvider`.
## Overview
The image preloading system on Android works by:
1. Downloading images from URLs to the internal app cache.
2. Rasterizing SVG inputs to PNG when needed.
3. Making these images available to Voltra widgets via the `assetName` property.
4. Providing APIs to reload widgets when new images are ready.
## API Reference
### `preloadImages(images: PreloadImageOptions[]): Promise`
Downloads images to the Android cache for use in Widgets.
Each item must be either a URL preload (`url`) or an SVG preload (`svg`). The TypeScript types express this as a union of two variants:
```typescript
type PreloadImageUrlOptions = {
key: string // The assetName to use when referencing this image
url: string // URL to download the image from
method?: 'GET' | 'POST' | 'PUT' // HTTP method (default: 'GET')
headers?: Record // Optional HTTP headers
width?: number
height?: number
}
type PreloadImageSvgOptions = {
key: string // The assetName to use when referencing this image
svg: string // Inline SVG markup to rasterize and cache as PNG
width?: number // Typically required for SVG rasterization (see native errors if omitted)
height?: number
}
type PreloadImageOptions = PreloadImageUrlOptions | PreloadImageSvgOptions
type PreloadImageFailure = {
key: string
error: string
}
type PreloadImagesResult = {
succeeded: string[] // Keys of successfully downloaded images
failed: PreloadImageFailure[] // Failed downloads with error messages
}
```
**Example:**
```typescript
import { preloadImages } from '@use-voltra/android-client'
const result = await preloadImages([
{
url: 'https://example.com/album-art.jpg',
key: 'current-album',
headers: { Authorization: 'Bearer token' },
},
{
key: 'status-icon-active',
svg: '',
width: 24,
height: 24,
},
])
if (result.succeeded.includes('status-icon-active')) {
// Images are ready to be used in widgets
}
```
### `reloadWidgets(widgetIds?: string[]): Promise`
Reloads Android widgets to pick up newly preloaded images. If no `widgetIds` are provided, all active widgets will be reloaded.
```typescript
import { reloadWidgets } from '@use-voltra/android-client'
// Reload all widgets
await reloadWidgets()
// Reload specific widgets
await reloadWidgets(['weather_widget'])
```
### `clearPreloadedImages(keys?: string[]): Promise`
Removes preloaded images from the Android cache. If no `keys` are provided, all preloaded images will be cleared.
```typescript
import { clearPreloadedImages } from '@use-voltra/android-client'
// Clear specific images
await clearPreloadedImages(['current-album'])
// Clear all preloaded images
await clearPreloadedImages()
```
## Usage in Android Widgets
Once images are preloaded, reference them using the `assetName` property in the `VoltraAndroid.Image` component:
```tsx
import { VoltraAndroid } from '@use-voltra/android'
function MusicWidget({ albumKey }) {
return (
)
}
```
---
url: /android/development/images.md
---
# Images
Voltra provides three different approaches for including images in your Android widgets, each with different trade-offs and use cases:
- **Build-time asset copying**: Best for static icons and assets known at build time
- **Runtime preloading**: Best for dynamic images from remote URLs
- **Base64 encoding**: Best for small, generated images
## Build-time asset copying
Place images in the `/assets/voltra-android/` directory and they'll be automatically processed and copied to the Android drawable resources during build.
```
project-root/
├── assets/
│ └── voltra-android/
│ ├── logo.png
│ ├── weather/
│ │ ├── sunny.svg
│ │ └── rainy.xml
│ └── background.webp
```
Here's how build-time asset copying works:
1. Images in `/assets/voltra-android/` are automatically detected during build (run `npx expo prebuild` to apply changes).
2. Filenames are sanitized to be compatible with Android resource naming rules (lowercase, underscores only).
3. SVGs are automatically converted to Android Vector Drawables (XML).
4. Images are copied to `res/drawable/` in the native Android project.
### Naming & Sanitization
Android drawable resources have strict naming conventions. Voltra automatically handles this for you:
- **Sanitization:** Uppercase letters are converted to lowercase. Hyphens and other special characters are replaced with underscores.
- **Flattening:** Subdirectories are flattened into the resource name to avoid conflicts.
**Examples:**
| Source File | Android Resource Name |
| :---------------------------------------- | :-------------------- |
| `assets/voltra-android/Logo.png` | `logo` |
| `assets/voltra-android/icons/My-Icon.png` | `icons_my_icon` |
| `assets/voltra-android/weather/sunny.svg` | `weather_sunny` |
### Supported Formats
- **Bitmap:** `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`
- **Vector:** `.svg` (converted to Vector Drawable), `.xml` (native Vector Drawable)
### Usage
Reference these images using their sanitized name in the `assetName` property. You do not need to include the file extension.
```tsx
import { VoltraAndroid } from '@use-voltra/android'
// assets/voltra-android/logo.png -> "logo"
// assets/voltra-android/weather/sunny.svg -> "weather_sunny"
```
## Runtime preloading
For dynamic images from remote URLs, use Voltra's image preloading API to cache images locally.
The image preloading system works by:
1. Downloading images from URLs to the app's internal cache.
2. Making images available to widgets via a local content provider.
3. Providing APIs to reload widgets when new images are ready.
Once images are preloaded, reference them using the key you provided:
```tsx
import { VoltraAndroid } from '@use-voltra/android'
function ProfileWidget({ user }) {
return (
{user.name}
)
}
```
For detailed API documentation, see [Image Preloading](/android/development/image-preloading.md).
## Base64 encoding
You can also embed images directly as base64-encoded strings. This is useful for small, generated images or when you want to avoid file management for very simple assets.
```tsx
```
## Comparison table
| Approach | When Known | Dynamic | Setup Required | Performance |
| :------------- | :------------ | :------ | :--------------- | :------------------------------- |
| **Build-time** | Build time | No | File placement | Best (Native Resource) |
| **Preloading** | Runtime | Yes | Preload API call | Good (Cached File) |
| **Base64** | Runtime/Build | No | None | Fair (Memory intensive if large) |
---
url: /android/development/managing-ongoing-notifications.md
---
# Managing Android Ongoing Notifications
:::warning Experimental API
Android ongoing notifications are **experimental**. The API may change in future releases.
:::
Voltra supports Android ongoing notifications for local, app-driven status updates such as deliveries, rides, workouts, or timers.
Use this API when you want to:
- start a persistent notification from your app
- update its content over time
- stop it when the task ends
- add action buttons that open deep links in your app
Voltra also supports remote updates if your app receives push notifications in the background and forwards the payload to the ongoing notification APIs.
## Server-side rendering support
Voltra already provides a server-side API for converting JSX into the semantic payload used by Android ongoing notifications.
Use these APIs only in server-side or backend code. Do not import them from your React Native app runtime.
Use `@use-voltra/android-server`.
The main renderer APIs are:
- `renderAndroidOngoingNotificationPayloadToJson()` returns an object
- `renderAndroidOngoingNotificationPayload()` returns a JSON string
This API only renders the payload. Your server still needs to send that payload through your push provider, and your app still needs a background task that calls `upsertAndroidOngoingNotification()` or `stopAndroidOngoingNotification()` when the push arrives.
## Before you start
### 1. Enable notification manifest support
Add `android.enableNotifications` to the Voltra Expo plugin config:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"enableNotifications": true
}
]
]
}
}
```
This adds the Android manifest entries required by Voltra's notification features.
See [Plugin Configuration](/android/api/plugin-configuration.md#enablenotifications-optional) for details.
### 2. Create a notification channel
`channelId` is required when starting an ongoing notification, and the channel must already exist.
If you use `expo-notifications`, you can create a channel like this:
```tsx
import * as Notifications from 'expo-notifications'
await Notifications.setNotificationChannelAsync('delivery_updates', {
name: 'Delivery updates',
importance: Notifications.AndroidImportance.DEFAULT,
})
```
### 3. Request notification permission on Android 13+
On Android 13 and above, posting notifications requires runtime permission.
```tsx
import {
hasAndroidNotificationPermission,
requestAndroidNotificationPermission,
} from '@use-voltra/android-client'
const granted =
(await hasAndroidNotificationPermission()) || (await requestAndroidNotificationPermission())
if (!granted) {
// Show your own UI explaining why notifications are needed.
}
```
### 4. If you want remote updates, register a background notification task
The playground app uses `expo-notifications` together with `expo-task-manager` to process real push notifications and update ongoing notifications in the background.
Register a background task early in app startup:
```tsx
import * as Notifications from 'expo-notifications'
import * as TaskManager from 'expo-task-manager'
const TASK_NAME = 'voltra-ongoing-notification-task'
TaskManager.defineTask(TASK_NAME, async ({ data, error }) => {
if (error) {
return
}
// Read your push payload and call Voltra APIs here.
})
await Notifications.registerTaskAsync(TASK_NAME)
```
The example app does this during startup so that incoming pushes can update or stop an ongoing notification even when the app is backgrounded.
## Starting a notification
Voltra provides two built-in layouts:
- `AndroidOngoingNotification.Progress`
- `AndroidOngoingNotification.BigText`
### Progress notification
```tsx
import { AndroidOngoingNotification } from '@use-voltra/android'
import {
startAndroidOngoingNotification,
} from '@use-voltra/android-client'
const result = await startAndroidOngoingNotification(
,
{
notificationId: 'order-123',
channelId: 'delivery_updates',
deepLinkUrl: 'myapp://orders/123',
}
)
if (result.ok) {
console.log('Started:', result.notificationId)
}
```
### Big text notification
```tsx
import { AndroidOngoingNotification } from '@use-voltra/android'
import {
startAndroidOngoingNotification,
} from '@use-voltra/android-client'
await startAndroidOngoingNotification(
,
{
notificationId: 'match-42',
channelId: 'sports_updates',
}
)
```
## Updating a notification
Use the same `notificationId` to update an existing notification.
```tsx
import { AndroidOngoingNotification } from '@use-voltra/android'
import {
updateAndroidOngoingNotification,
} from '@use-voltra/android-client'
await updateAndroidOngoingNotification(
'order-123',
)
```
`updateAndroidOngoingNotification()` returns a result object. If the notification no longer exists, it returns `reason: 'not_found'` or `reason: 'dismissed'`.
## Starting or updating with one call
If your app may re-enter the same flow multiple times, `upsertAndroidOngoingNotification()` can be easier than separate start/update logic.
```tsx
import { AndroidOngoingNotification } from '@use-voltra/android'
import {
upsertAndroidOngoingNotification,
} from '@use-voltra/android-client'
const result = await upsertAndroidOngoingNotification(
,
{
notificationId: 'workout-1',
channelId: 'fitness_updates',
}
)
if (result.ok) {
console.log(result.action) // 'started' or 'updated'
}
```
This API is especially useful for remote updates, where the same incoming push may need to create the notification the first time and update it later.
## Stopping a notification
```tsx
import { stopAndroidOngoingNotification } from '@use-voltra/android-client'
await stopAndroidOngoingNotification('order-123')
```
To dismiss every active Voltra ongoing notification at once:
```tsx
import { endAllAndroidOngoingNotifications } from '@use-voltra/android-client'
await endAllAndroidOngoingNotifications()
```
## Hook API
For React screens and flows, use `useAndroidOngoingNotification()`.
```tsx
import { AndroidOngoingNotification } from '@use-voltra/android'
import { useAndroidOngoingNotification } from '@use-voltra/android-client'
function DeliveryNotification({ orderId, etaMinutes }) {
const { start, update, end, isActive } = useAndroidOngoingNotification(
,
{
notificationId: `order-${orderId}`,
channelId: 'delivery_updates',
deepLinkUrl: `myapp://orders/${orderId}`,
autoStart: true,
autoUpdate: true,
}
)
return null
}
```
The hook returns:
- `start()`
- `update()`
- `end()`
- `isActive`
Use `autoStart` to create the notification when the component mounts, and `autoUpdate` to refresh it when the JSX content changes.
## Action buttons
You can add action buttons as children of `Progress` or `BigText`.
```tsx
import { AndroidOngoingNotification } from '@use-voltra/android'
```
Action buttons currently:
- open the provided deep link
- can be used with `Progress` and `BigText`
- support an optional `icon`
```tsx
```
Android may not show action icons in the standard notification UI, so treat them as optional enhancement rather than a guaranteed visual element.
## Remote updates
Voltra can apply remote ongoing-notification updates if your app receives a push notification and handles it in a background task.
The end-to-end flow is:
1. Your server renders Voltra JSX into an Android ongoing-notification payload.
2. Your server sends a high-priority push notification.
3. The push `data` contains a `voltraOngoingNotification` object.
4. Your background task parses that object.
5. The task calls `upsertAndroidOngoingNotification()` or `stopAndroidOngoingNotification()`.
### 1. Render the payload on your server
Use `renderAndroidOngoingNotificationPayloadToJson()` when preparing a payload on your server or in app tooling:
```tsx
import {
AndroidOngoingNotification,
renderAndroidOngoingNotificationPayloadToJson,
} from '@use-voltra/android-server'
const payload = renderAndroidOngoingNotificationPayloadToJson(
)
```
Then send that payload inside a push message.
If your push provider expects strings for nested payload data, use `renderAndroidOngoingNotificationPayload()` instead and send the JSON string directly.
### 2. Send the payload through your push provider
The playground app expects `data.voltraOngoingNotification` to contain:
- `notificationId`: the stable notification identifier
- `operation`: `'upsert'` or `'stop'`
- `options`: start options such as `channelId`, `smallIcon`, `deepLinkUrl`, `requestPromotedOngoing`, or `fallbackBehavior`
- `payload`: the Voltra semantic payload for `'upsert'`
Example Expo push request:
```json
{
"to": "ExponentPushToken[project-token]",
"priority": "high",
"data": {
"voltraOngoingNotification": "{\"notificationId\":\"order-123\",\"operation\":\"upsert\",\"options\":{\"channelId\":\"delivery_updates\",\"deepLinkUrl\":\"myapp://orders/123\",\"requestPromotedOngoing\":true},\"payload\":{\"v\":1,\"kind\":\"progress\",\"title\":\"Driver is approaching\",\"text\":\"2 stops away\",\"value\":80,\"max\":100}}"
}
}
```
The playground app accepts either an object or a JSON string for `data.voltraOngoingNotification`. Stringifying it is often the safest option when sending through push providers.
To stop the notification remotely, send the same `notificationId` with `operation: "stop"` and omit `payload`.
### 3. Apply the payload in your background task
```tsx
import * as Notifications from 'expo-notifications'
import * as TaskManager from 'expo-task-manager'
import {
stopAndroidOngoingNotification,
upsertAndroidOngoingNotification,
} from '@use-voltra/android-client'
const TASK_NAME = 'voltra-ongoing-notification-task'
const parseMessage = (value: unknown) => {
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return null
}
}
return value
}
TaskManager.defineTask(TASK_NAME, async ({ data, error }) => {
if (error) {
return
}
const message = parseMessage(data?.voltraOngoingNotification)
if (!message || typeof message !== 'object') {
return
}
const notificationId = typeof message.notificationId === 'string' ? message.notificationId : null
if (!notificationId) {
return
}
if (message.operation === 'stop') {
await stopAndroidOngoingNotification(notificationId)
return
}
if (!message.payload || !message.options?.channelId) {
return
}
await upsertAndroidOngoingNotification(message.payload, {
...message.options,
notificationId,
})
})
await Notifications.registerTaskAsync(TASK_NAME)
```
### Channel setup for remote updates
Your background task should ensure that the target notification channel exists before calling `upsertAndroidOngoingNotification()`. The playground app creates the channel on startup and also ensures it exists again inside the background handler.
### Important notes
- Voltra does include a server-side JSX-to-payload renderer for Android ongoing notifications.
- Remote updates depend on your push provider and app-level background notification setup.
- Voltra provides the ongoing-notification rendering and lifecycle APIs, but your app is responsible for receiving the push and invoking those APIs.
- `upsertAndroidOngoingNotification()` is the easiest entry point for remote updates because it can create or update the notification with the same payload path.
- If your push provider serializes nested objects as strings, parse `data.voltraOngoingNotification` before passing it to Voltra.
## Main tap behavior
Use `deepLinkUrl` in the start or update options to control what happens when the user taps the main notification body:
```tsx
await startAndroidOngoingNotification(content, {
notificationId: 'order-123',
channelId: 'delivery_updates',
deepLinkUrl: 'myapp://orders/123',
})
```
This is separate from action button deep links.
## Status and capability helpers
Use these helpers to adapt your UI to the device state:
```tsx
import {
canPostPromotedAndroidNotifications,
getAndroidOngoingNotificationCapabilities,
getAndroidOngoingNotificationStatus,
openAndroidNotificationSettings,
} from '@use-voltra/android-client'
const status = getAndroidOngoingNotificationStatus('order-123')
const capabilities = getAndroidOngoingNotificationCapabilities()
const canPostPromoted = canPostPromotedAndroidNotifications()
if (!capabilities.notificationsEnabled) {
await openAndroidNotificationSettings()
}
```
Useful values include:
- `status.isActive`
- `status.isDismissed`
- `capabilities.notificationsEnabled`
- `capabilities.supportsPromotedNotifications`
- `capabilities.canPostPromotedNotifications`
- `capabilities.canRequestPromotedOngoing`
## Promoted ongoing notifications
If your app wants to request promoted ongoing presentation when the device supports it, pass `requestPromotedOngoing: true`:
```tsx
await startAndroidOngoingNotification(content, {
notificationId: 'ride-44',
channelId: 'ride_updates',
requestPromotedOngoing: true,
})
```
You can also set `fallbackBehavior` if promoted presentation is unavailable:
```tsx
await startAndroidOngoingNotification(content, {
notificationId: 'ride-44',
channelId: 'ride_updates',
requestPromotedOngoing: true,
fallbackBehavior: 'standard',
})
```
Check device support first with `getAndroidOngoingNotificationCapabilities()` if you want to tailor the UX.
## Current limitations
- Remote updates require your own push delivery and background task integration.
- Your app must create the Android notification channel before starting a notification.
- Notification permission still needs to be requested by your app on Android 13+.
- Action buttons open deep links. They are not a JavaScript event system.
---
url: /android/development/querying-active-widgets.md
---
# Querying Active Widgets
On Android, you can detect every active instance of your widgets currently placed on the Home Screen. This is particularly useful for Android since each widget instance can have different dimensions and a unique `widgetId`.
## getActiveWidgets API
The `getActiveWidgets` function returns a promise that resolves to an array of all active widget instances for your app.
```typescript
import { getActiveWidgets } from '@use-voltra/android-client'
async function checkAndroidWidgets() {
const activeWidgets = await getActiveWidgets()
console.log(`Found ${activeWidgets.length} active widget instances`)
activeWidgets.forEach(widget => {
console.log(`- Widget Name: ${widget.name}`)
console.log(` ID: ${widget.widgetId}`)
console.log(` Size: ${widget.width}x${widget.height}dp`)
})
}
```
### WidgetInfo Object
Each object in the returned array contains:
| Property | Type | Description |
| :------------------ | :------- | :------------------------------------------------------------------------------------------- |
| `name` | `string` | The unique ID of the widget as defined in your Expo config plugin (e.g., `"weather"`). |
| `widgetId` | `number` | The unique system identifier for this specific widget instance. |
| `providerClassName` | `string` | The full class name of the widget provider (e.g., `".widget.VoltraWidget_weatherReceiver"`). |
| `label` | `string` | The human-readable label shown in the Android widget picker. |
| `width` | `number` | The current width of the widget instance in dp. |
| `height` | `number` | The current height of the widget instance in dp. |
---
url: /android/development/server-driven-widgets.md
---
# Server-driven widgets
Server-driven widgets allow your Android Home Screen widgets to periodically fetch fresh content from a remote server—without the user opening the app. This is powered by WorkManager, which handles scheduling, retries, and network constraints automatically.
Before you start, make sure the widget is registered in the Voltra plugin config and plan to rebuild the native app after adding or changing server-driven widget settings.
Android semantic color tokens from [`AndroidDynamicColors`](/android/development/dynamic-colors.md) work in server-rendered widgets too, so your backend can return dynamic Material roles instead of fixed hex values.
## How it works
1. You configure a `serverUpdate` URL in your Android widget's plugin config
2. WorkManager runs a periodic background task at the configured interval
3. Your server renders Voltra JSX components into a JSON payload
4. The worker parses the payload and pushes a `RemoteViews` update to the widget
Your app doesn't need to be running. WorkManager handles everything in the background.
## Plugin configuration
Add the `serverUpdate` option to your Android widget in `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"widgets": [
{
"id": "dynamic_weather",
"displayName": "Dynamic Weather",
"description": "Weather with live server updates",
"targetCellWidth": 2,
"targetCellHeight": 1,
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 60
}
}
]
}
]
]
}
}
```
**`serverUpdate` options:**
- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=android&theme=dark`).
- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. The minimum effective interval is 15 minutes (WorkManager requirement).
- `refresh`: Whether to show a native refresh button in the top-right corner of the widget. When tapped, triggers an immediate server fetch. Defaults to `false`.
After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native widget code picks up the new server update settings.
:::note
On the Android emulator, use `10.0.2.2` instead of `localhost` to reach the host machine. Real devices need the host's LAN IP address.
:::
## Building the server
Voltra provides widget server handlers for the common runtime styles. Use `createAndroidWidgetUpdateHandler()` for Fetch-compatible runtimes, `createAndroidWidgetUpdateNodeHandler()` for `node:http`, and `createAndroidWidgetUpdateExpressHandler()` for Express-style handlers. All three share the same request parsing, platform validation, token validation, and response serialization.
```tsx
import { createServer } from 'node:http'
import React from 'react'
import { createAndroidWidgetUpdateNodeHandler } from '@use-voltra/android-server'
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
const handler = createAndroidWidgetUpdateNodeHandler({
render: async (req) => {
// req.widgetId — the widget requesting an update
// req.platform — always "android" for Android widget requests
// req.theme — the system color scheme ("light" or "dark")
// req.token — the auth token (if credentials were set)
const weather = await fetchWeatherData()
const content = (
{weather.temp}°
{weather.condition}
)
// Return size breakpoints for different widget sizes
return [
{ size: { width: 200, height: 100 }, content },
{ size: { width: 200, height: 200 }, content },
{ size: { width: 300, height: 200 }, content },
]
},
validateToken: async (token) => {
return token === 'valid-token'
},
})
createServer(handler).listen(3333)
```
The handler responds to GET requests with these query parameters:
| Parameter | Description |
| ---------- | ------------------------------------------------------ |
| `widgetId` | The widget identifier (required) |
| `platform` | The requesting platform. Must be `android` (required). |
| `family` | Not used on Android |
| `theme` | The system color scheme (`light` or `dark`) |
The `User-Agent` header is set to `VoltraWidget/ (Android/)`.
## Authentication
Widgets on Android are part of the main app binary, so the WorkManager background worker can access credential storage directly. Voltra encrypts credentials at rest using **Google Tink** (AES-256-GCM with Android Keystore-backed key management) and persists them in Jetpack DataStore.
### Setting credentials
Call `setWidgetServerCredentials` after the user logs in:
```typescript
import { setWidgetServerCredentials } from '@use-voltra/android-client'
await setWidgetServerCredentials({
token: userAccessToken,
headers: {
'X-App-Version': '1.0.0',
},
})
```
The `token` is required and is sent as `Authorization: Bearer ` on every server request. Any additional `headers` are also included. If your widget endpoint does not require authentication, skip `setWidgetServerCredentials()` entirely.
### Clearing credentials
Call `clearWidgetServerCredentials` when the user logs out:
```typescript
import { clearWidgetServerCredentials } from '@use-voltra/android-client'
await clearWidgetServerCredentials()
```
All widgets are automatically reloaded after credentials are cleared, so they revert to their default/unauthenticated state immediately.
## Refresh button
Server-driven widgets can display a native refresh button that lets users trigger an immediate update on demand. Enable it in your widget config:
```json
{
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 60,
"refresh": true
}
}
```
When enabled, a small circular button (↻) appears in the top-right corner of the widget. Tapping it performs an inline HTTP fetch, generates new `RemoteViews`, and pushes the update directly to the widget—all without waiting for the next WorkManager cycle.
:::note
The refresh callback bypasses Glance's `update()` method (which doesn't reliably trigger `provideGlance()`) and instead uses `GlanceRemoteViews.compose()` to generate `RemoteViews` that are pushed directly via `AppWidgetManager.updateAppWidget()`.
:::
## Resize handling
Your server should return all size variants in every response. When the user resizes a widget on the home screen, Voltra re-renders from cached data—no network request is made. The `RemoteViews(sizeMapping)` mechanism automatically picks the closest matching variant.
## Triggering manual refreshes
You can force-refresh server-driven widgets outside of the regular interval:
```typescript
import { reloadAndroidWidgets } from '@use-voltra/android-client'
// Reload specific widgets (triggers an immediate WorkManager fetch)
await reloadAndroidWidgets(['dynamic_weather'])
// Reload all widgets
await reloadAndroidWidgets()
```
For server-driven widgets, this enqueues an immediate one-time WorkManager request to fetch fresh content. For local-only widgets, it re-renders from cached data.
## Initial state
Server-driven widgets still need content to display before the first server fetch completes. Use `initialStatePath` to provide a pre-rendered default:
```json
{
"id": "dynamic_weather",
"displayName": "Dynamic Weather",
"description": "Weather with live server updates",
"targetCellWidth": 2,
"targetCellHeight": 1,
"initialStatePath": "./widgets/android/weather-initial.tsx",
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 60
}
}
```
See [Widget pre-rendering](/android/development/widget-pre-rendering.md) for details on creating initial state files.
:::tip
Provide a meaningful initial state (e.g. "Loading..." or placeholder content) rather than leaving it empty. The user sees this until the first server fetch succeeds.
:::
## Cross-platform server
A single server can handle both iOS and Android requests using `createWidgetUpdateHandler` from `@use-voltra/server`:
```tsx
import { Voltra } from '@use-voltra/ios'
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
import { createWidgetUpdateHandler } from '@use-voltra/server'
const handler = createWidgetUpdateHandler({
renderIos: async (req) => {
// Return WidgetVariants (systemSmall, systemMedium, etc.)
return { systemSmall: Hello }
},
renderAndroid: async (req) => {
// Return AndroidWidgetVariants (size breakpoints)
return [{ size: { width: 200, height: 100 }, content: Hello }]
},
validateToken: async (token) => {
// Shared token validation for both platforms
return verifyJwt(token)
},
})
```
The handler uses the required `platform` query parameter to route requests to the correct render function.
If you're serving the cross-platform endpoint from Node or Express, use `createWidgetUpdateNodeHandler()` or `createWidgetUpdateExpressHandler()` from `@use-voltra/server` instead.
## Architecture overview
```
┌─────────────────┐ setWidgetServerCredentials() ┌─────────────────────────┐
│ React Native │ ─────────────────────────────► │ EncryptedSharedPrefs │
│ (main app) │ └─────────────────────────┘
└─────────────────┘ │
│ reads token
▼
┌─────────────────┐ GET ?widgetId=X&platform=android&theme=Y ┌──────────────────┐
│ WorkManager │ ─────────────────────────────► │ Your Server │
│ (background) │ ◄───────────────────────────── │ (Voltra SSR) │
└─────────────────┘ JSON payload └──────────────────┘
│
▼
AppWidgetManager
(RemoteViews update)
│
▼
Home Screen Widget
```
WorkManager handles scheduling, network constraints, and retries. The background worker reads credentials from encrypted storage, makes the HTTP request, parses the response, generates `RemoteViews`, and pushes the update via `AppWidgetManager`.
## Error handling and retries
WorkManager automatically handles failures with exponential backoff. After 5 consecutive failed attempts, the worker gives up to avoid infinite retry loops. The next periodic run will start fresh.
- **Network unavailable:** The request is deferred until connectivity is restored (via `NetworkType.CONNECTED` constraint).
- **Server errors (non-2xx):** The worker retries with exponential backoff, up to 3 attempts.
- **Empty response:** The worker retries with exponential backoff, up to 3 attempts.
- **Parse errors:** If the JSON is stored but parsing fails, the data is still saved so Glance can attempt to use it later. This counts as a success since the data is persisted.
---
url: /android/development/styling.md
---
# Styling
You can style Voltra components on Android using React Native-style `style` props. These properties are automatically converted to Jetpack Compose Glance modifiers.
For Android system-aware colors, use [`AndroidDynamicColors`](/android/development/dynamic-colors.md) from `@use-voltra/android` instead of snapshotting palette values in JavaScript.
:::warning Glance Limitations
Android widgets are built using **Jetpack Compose Glance**, which has a significantly more limited styling API compared to standard Compose or SwiftUI. Many common React Native style properties are either not supported or have limited support.
:::
## Supported Properties
The following React Native style properties are supported on Android:
### Layout
- `width`, `height` - Fixed dimensions (number values in dp) or `"100%"` to fill available space.
- `flex`, `flexGrow` - Flex weight. When > 0, the component will take up a proportional amount of space in its parent container (maps to `.defaultWeight()` in Glance).
- `padding` - Uniform padding on all edges.
- `paddingTop`, `paddingBottom`, `paddingLeft`, `paddingRight` - Individual edge padding.
- `paddingHorizontal`, `paddingVertical` - Horizontal and vertical padding.
- `visibility` - Controls component visibility (`"visible"`, `"hidden"`, or `"invisible"`).
### Visual Style
- `backgroundColor` - Background color (hex strings, color names, or `AndroidDynamicColors.*` tokens).
- `backgroundImage` - CSS gradient background. Supports `linear-gradient(...)`, `radial-gradient(...)`, and `conic-gradient(...)`.
- `borderRadius` - Corner radius value. **Note:** Requires Android 12+ (API 31). On older versions, this property is ignored.
### Text
- `fontSize` - Font size in sp.
- `fontWeight` - Supports `"normal"` and `"bold"`.
- `fontFamily` - Font family name. Built-in values: `"monospace"`, `"serif"`, `"sans-serif"`, `"cursive"`. For custom fonts, see [Custom Fonts](/android/development/custom-fonts.md).
- `color` - Text color (literal colors or `AndroidDynamicColors.*`).
- `textDecorationLine` - Supports `"underline"` and `"line-through"`.
- `textAlign` - Alignment of text within the component (`"left"`, `"center"`, `"right"`).
- `numberOfLines` - Limits the number of lines displayed.
### Image Specific
In addition to general styles, `Image` components support:
- `resizeMode` - `"cover"`, `"contain"`, `"stretch"`, `"repeat"`, or `"center"`.
- `contentScale` - `"crop"`, `"cover"`, `"fit"`, `"contain"`, `"fill-bounds"`, or `"stretch"`.
- `alpha` - Opacity of the image (0.0 to 1.0).
- `colorFilter` - Applies a color filter to the image.
## Dynamic colors
Android widgets can use semantic Material color roles that resolve through native `GlanceTheme.colors.*` values during rendering.
```tsx
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
const element = (
Android Widget Text
)
```
This is the preferred approach when you want widgets to follow Android's dynamic palette even when the app is not running. See [Dynamic Colors](/android/development/dynamic-colors.md) for the full role list and server-rendering behavior.
## Gradient Backgrounds
Android widgets support gradient backgrounds through the camel-case `style.backgroundImage` property.
```tsx
import { VoltraAndroid } from '@use-voltra/android'
const element = (
Gradient Widget
)
```
Supported gradient functions are `linear-gradient(...)`, `radial-gradient(...)`, and `conic-gradient(...)`. Repeating gradients, malformed gradients, unsupported color tokens, and invalid stop positions are ignored. If `backgroundColor` is also provided, Android paints it behind transparent gradient pixels and uses it as a fallback when a gradient cannot be rendered.
Gradient stops can use Android dynamic color tokens from `AndroidDynamicColors`, but those colors are resolved into the generated bitmap when the widget renders or updates. Existing gradient bitmaps do not recolor until the widget is rendered again.
Use `backgroundImage`, not `background-image`. Gradient bitmaps are generated natively during widget rendering and capped before being passed to Glance, so the bitmap does not control layout size.
## Limitations
The following properties are **NOT supported** on Android due to Glance limitations:
- **Margins:** `margin`, `marginTop`, etc. are not part of Android style types. If you need margin-like outside spacing, use `VoltraAndroid.Spacer` between elements.
- **Borders:** `borderWidth` and `borderColor` are not yet implemented.
- **Shadows:** `shadowColor`, `shadowOffset`, `shadowOpacity`, and `shadowRadius` are not supported.
- **Positioning:** Absolute positioning (`top`, `left`, `zIndex`) is not supported. Use stack alignments and spacers.
- **Transforms:** `transform` (rotate, scale, etc.) is not supported.
- **Opacity:** The general `style.opacity` property is not supported (except for the `alpha` prop on `Image`).
- **Dimensions:** `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, and `aspectRatio` are not supported.
- **Text Effects:** `letterSpacing`, `fontVariant`, and custom `lineHeight` are not supported.
## Example
```tsx
import { VoltraAndroid } from '@use-voltra/android'
const element = (
Android Widget Text
)
```
---
url: /android/development/testing-and-previews.md
---
# Testing and Previews (Android)
Voltra provides multiple ways to preview your Android widgets:
1. **In-App Previews** - Preview layouts within your development app using `VoltraWidgetPreview`
2. **Widget Picker Previews** - Customize what users see in the Android widget picker when adding your widget
This page covers in-app previews for development. For widget picker previews, see [Plugin Configuration - Widget Picker Previews](/android/api/plugin-configuration.md#widget-picker-previews).
## VoltraWidgetPreview
The `VoltraWidgetPreview` component renders Voltra Android JSX content at the exact dimensions of standard Android widget sizes.
### Usage
```tsx
import { VoltraAndroid } from '@use-voltra/android'
import { VoltraWidgetPreview } from '@use-voltra/android-client'
export function MyWidgetPreview() {
return (
My Awesome Widget
This is how it looks on the home screen!
)
}
```
### Supported Families
Android widgets use responsive sizing. Voltra provides several standard families based on typical grid dimensions:
| Family | DP Dimensions | Typical Grid Size |
| :------------- | :------------ | :---------------- |
| `small` | 150 x 100 | 2x1 |
| `mediumSquare` | 200 x 200 | 2x2 |
| `mediumWide` | 250 x 150 | 3x2 |
| `mediumTall` | 150 x 250 | 2x3 |
| `large` | 300 x 200 | 4x2 |
| `extraLarge` | 350 x 300 | 4x4 |
## VoltraView (Android)
If you need more control or want to test custom dimensions, you can use the low-level `VoltraView` component.
```tsx
import { VoltraAndroid } from '@use-voltra/android'
import { VoltraView } from '@use-voltra/android-client'
Custom Preview
```
## Accuracy
The Android preview components use the **actual native Glance renderers** under the hood. When you provide JSX to `VoltraWidgetPreview`, it is converted to a native `RemoteViews` object and rendered using the same logic that Android uses on the home screen.
This ensures that:
- Layout constraints are respected.
- Styling (colors, fonts, spacing) is accurate.
- Component mapping is identical to the production widget.
:::info
While layout and styling are accurate, some home-screen specific behaviors (like actual widget resizing by the user) are not simulated by the preview component.
:::
---
url: /android/development/widget-pre-rendering.md
---
# Widget Pre-rendering (Android)
Widget pre-rendering allows you to provide a meaningful initial state for your Android widgets before they are updated by the app for the first time.
## Configuration
Add `initialStatePath` to your widget configuration in the `@use-voltra/android-client` plugin in `app.json`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Shows current weather",
"targetCellWidth": 2,
"targetCellHeight": 2,
"initialStatePath": "./widgets/weather-android-initial.tsx"
}
]
}
]
]
}
}
```
For multiple locales, set `initialStatePath` to a map of locale tag → file path (same pattern as iOS); the first-run payload matches the device locale when possible.
## Implementation
Create a file at the specified `initialStatePath` that exports a default Voltra component (or a React element). For Android, this should use `VoltraAndroid` primitives.
```tsx
import { VoltraAndroid } from '@use-voltra/android'
const InitialWeatherWidget = (
Loading weather...
)
export default InitialWeatherWidget
```
:::info
`initialStatePath` files are **not** part of your React Native app bundle. They run in Node.js during prebuild. Import `VoltraAndroid` from `@use-voltra/android`, not `@use-voltra/android-client` — the client package pulls in native modules that are unavailable in the prebuild sandbox.
:::
## Build Process
During the build process (`npx expo prebuild`), Voltra executes these initial state files in a Node.js environment to generate the static layouts that will be displayed when the widget is first added to the home screen.
## Limitations
- **Environment**: The code runs in Node.js during build time, not on the device.
- **Imports**: Use `@use-voltra/android` for JSX and types in `initialStatePath` files. Do not import from `@use-voltra/android-client` or other React Native client APIs.
- **Static Content**: The initial state should represent a "loading" or "offline" state, as it won't have access to dynamic runtime data until the app runs.
---
url: /android/setup.md
---
# Android Setup
Once you have [installed Voltra](/getting-started/installation.md) for Android, configure the Expo plugin and use `@use-voltra/android` for widget JSX.
## 1. Configure the Expo Plugin
Add `@use-voltra/android-client` to your `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"enableNotifications": true,
"widgets": [
{
"id": "my_widget",
"displayName": "My First Widget",
"description": "A simple Voltra widget",
"targetCellWidth": 2,
"targetCellHeight": 2
}
]
}
]
]
}
}
```
See [Plugin Configuration](/android/api/plugin-configuration.md) for all options.
## 2. Prebuild for Android
Update your native Android project:
```sh [npx]
npx expo prebuild --platform android
```
```sh [yarn]
yarn dlx expo prebuild --platform android
```
```sh [pnpm]
pnpm dlx expo prebuild --platform android
```
```sh [bunx]
bunx expo prebuild --platform android
```
## 3. Run the app
```bash
npx expo run:android
```
Your widget should now be available in the Android widget picker!
---
url: /getting-started/installation.md
---
# Installation
Voltra v2 ships as platform packages. In most apps, you install one package for JSX primitives and one package for runtime APIs per platform.
## 1. Install packages
### iOS (Live Activities and widgets)
```sh [npm]
npm install @use-voltra/ios @use-voltra/ios-client
```
```sh [yarn]
yarn add @use-voltra/ios @use-voltra/ios-client
```
```sh [pnpm]
pnpm add @use-voltra/ios @use-voltra/ios-client
```
```sh [bun]
bun add @use-voltra/ios @use-voltra/ios-client
```
```sh [deno]
deno add npm:@use-voltra/ios npm:@use-voltra/ios-client
```
### Android (Home Screen widgets and ongoing notifications)
```sh [npm]
npm install @use-voltra/android @use-voltra/android-client
```
```sh [yarn]
yarn add @use-voltra/android @use-voltra/android-client
```
```sh [pnpm]
pnpm add @use-voltra/android @use-voltra/android-client
```
```sh [bun]
bun add @use-voltra/android @use-voltra/android-client
```
```sh [deno]
deno add npm:@use-voltra/android npm:@use-voltra/android-client
```
Use the platform package for JSX primitives:
- `@use-voltra/ios` exports `Voltra`
- `@use-voltra/android` exports `VoltraAndroid`, `AndroidDynamicColors`, and `AndroidOngoingNotification`
Use the client package for React Native runtime APIs:
- `@use-voltra/ios-client` exports `startLiveActivity`, `updateWidget`, `VoltraView`, `VoltraWidgetPreview`, and other app-side APIs
- `@use-voltra/android-client` exports `updateAndroidWidget`, `requestPinAndroidWidget`, `reloadAndroidWidgets`, `VoltraView`, `VoltraWidgetPreview`, and other app-side APIs
### Server rendering (optional)
Use these in your backend or SSR service-not in the React Native app bundle:
- **iOS payloads:** `@use-voltra/ios-server`
- **Android payloads:** `@use-voltra/android-server`
- **Cross-platform widget HTTP handlers:** `@use-voltra/server`
## 2. Import patterns
The docs use these package boundaries consistently:
- React Native app code: JSX from `@use-voltra/ios` or `@use-voltra/android`, lifecycle APIs from the matching `*-client` package
- Node/server code: JSX plus render helpers from `@use-voltra/ios-server` or `@use-voltra/android-server`
- Pre-render files such as `initialStatePath`: import from `@use-voltra/ios` or `@use-voltra/android`, not from client packages
## 3. Next Steps
After installing packages, configure the Voltra Expo plugin for each platform you support:
- [Configure iOS Setup](/ios/setup.md)
- [Configure Android Setup](/android/setup.md)
- [Use Voltra in React Native CLI projects](/getting-started/react-native-cli.md)
- [Migrate from v1 to v2](/getting-started/migration-v2.md)
---
url: /getting-started/migration-v2.md
---
# Migration to v2
Voltra v2 introduces two major architectural changes:
- The old Voltra umbrella package is gone. Voltra now ships as separate iOS, Android, and server packages.
- The native layer moved from Expo Modules to Turbo Modules.
We made these changes to fix a few long-standing problems in the old package layout.
The old umbrella package made it too easy for React Native code to leak into server builds, especially when people only wanted server-side rendering or pre-rendering. It also forced many apps to pull in both platform surfaces even when they only shipped iOS or only shipped Android.
The Turbo Module migration also changes the native integration layer, so upgrading to v2 requires updating package installs, Expo plugin configuration, and some API usage.
This guide walks through the package, import, and configuration changes you need to make when upgrading to v2.
## What changed
### Package split
Old setups commonly used package paths like:
- `voltra`
- `voltra/client`
- `voltra/server`
- `voltra/android`
- `voltra/android/client`
- `voltra/android/server`
v2 uses scoped packages instead:
- `@use-voltra/ios`
- `@use-voltra/ios-client`
- `@use-voltra/ios-server`
- `@use-voltra/android`
- `@use-voltra/android-client`
- `@use-voltra/android-server`
- `@use-voltra/server`
### Platform-specific JSX namespaces
The old docs implied one shared component namespace. v2 documents the real platform split:
- iOS JSX primitives come from `@use-voltra/ios` as `Voltra`
- Android JSX primitives come from `@use-voltra/android` as `VoltraAndroid`
- Android semantic color tokens also live in `@use-voltra/android` as `AndroidDynamicColors`
- Android ongoing notification JSX lives in `@use-voltra/android` as `AndroidOngoingNotification`
### Client APIs stay in `*-client`
Use `@use-voltra/ios-client` and `@use-voltra/android-client` for runtime APIs that run inside the React Native app, such as:
- starting or updating Live Activities
- updating widgets
- pinning Android widgets
- preview components such as `VoltraView` and `VoltraWidgetPreview`
- event listeners and hooks
### Server APIs are platform-specific
Use platform server packages for server-side rendering:
- `@use-voltra/ios-server` for Live Activities and iOS widgets
- `@use-voltra/android-server` for Android widgets and ongoing notification payloads
Use `@use-voltra/server` only for cross-platform widget HTTP handlers.
## Installation changes
Install the packages that match the layers you use.
### iOS app code
```sh [npm]
npm install @use-voltra/ios @use-voltra/ios-client
```
```sh [yarn]
yarn add @use-voltra/ios @use-voltra/ios-client
```
```sh [pnpm]
pnpm add @use-voltra/ios @use-voltra/ios-client
```
```sh [bun]
bun add @use-voltra/ios @use-voltra/ios-client
```
```sh [deno]
deno add npm:@use-voltra/ios npm:@use-voltra/ios-client
```
### Android app code
```sh [npm]
npm install @use-voltra/android @use-voltra/android-client
```
```sh [yarn]
yarn add @use-voltra/android @use-voltra/android-client
```
```sh [pnpm]
pnpm add @use-voltra/android @use-voltra/android-client
```
```sh [bun]
bun add @use-voltra/android @use-voltra/android-client
```
```sh [deno]
deno add npm:@use-voltra/android npm:@use-voltra/android-client
```
### Optional server packages
```sh [npm]
npm install @use-voltra/ios-server @use-voltra/android-server @use-voltra/server
```
```sh [yarn]
yarn add @use-voltra/ios-server @use-voltra/android-server @use-voltra/server
```
```sh [pnpm]
pnpm add @use-voltra/ios-server @use-voltra/android-server @use-voltra/server
```
```sh [bun]
bun add @use-voltra/ios-server @use-voltra/android-server @use-voltra/server
```
```sh [deno]
deno add npm:@use-voltra/ios-server npm:@use-voltra/android-server npm:@use-voltra/server
```
## Import mapping
### iOS app code
```tsx
// v1
import { Voltra } from 'voltra'
import { startLiveActivity } from 'voltra/client'
// v2
import { Voltra } from '@use-voltra/ios'
import { startLiveActivity } from '@use-voltra/ios-client'
```
### iOS server code
```tsx
// v1
import { renderLiveActivityToString, Voltra } from 'voltra/server'
// v2
import { Voltra, renderLiveActivityToString } from '@use-voltra/ios-server'
```
### Android widget code
```tsx
// v1
import { VoltraAndroid } from 'voltra/android'
import { updateWidget } from 'voltra/android'
// v2
import { VoltraAndroid } from '@use-voltra/android'
import { updateAndroidWidget } from '@use-voltra/android-client'
```
### Android server widget code
```tsx
// v1
import { createWidgetUpdateNodeHandler } from 'voltra/server'
import { AndroidDynamicColors, VoltraAndroid } from 'voltra/android'
// v2
import { createAndroidWidgetUpdateNodeHandler } from '@use-voltra/android-server'
import { AndroidDynamicColors, VoltraAndroid } from '@use-voltra/android'
```
## Expo plugin migration
The old docs used a single `voltra` plugin and nested per-platform config. v2 uses platform-specific plugins.
### iOS
```json
// v1
{
"expo": {
"plugins": [
[
"voltra",
{
"ios": {
"groupIdentifier": "group.example.app",
"widgets": []
}
}
]
]
}
}
// v2
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.example.app",
"widgets": []
}
]
]
}
}
```
### Android
```json
// v1
{
"expo": {
"plugins": [
[
"voltra",
{
"android": {
"widgets": []
}
}
]
]
}
}
// v2
{
"expo": {
"plugins": [
[
"@use-voltra/android-client",
{
"widgets": []
}
]
]
}
}
```
## API renames to watch for
- Android widget updates: `updateWidget(...)` -> `updateAndroidWidget(...)`
- Android widget reloads: `reloadWidgets(...)` for image-preloading remains, but widget refresh APIs use `reloadAndroidWidgets(...)`
- Android widget server handlers: `createWidgetUpdateHandler(...)` style examples should become `createAndroidWidgetUpdateHandler(...)`, `createAndroidWidgetUpdateNodeHandler(...)`, or `createAndroidWidgetUpdateExpressHandler(...)`
- iOS widget server handlers: use `createIOSWidgetUpdateHandler(...)`, `createIOSWidgetUpdateNodeHandler(...)`, or `createIOSWidgetUpdateExpressHandler(...)`
## Pre-render files
`initialStatePath` files run in Node during prebuild, not in the React Native runtime.
Use:
- `@use-voltra/ios` for iOS widget JSX and types
- `@use-voltra/android` for Android widget JSX and types
Do not import `@use-voltra/ios-client` or `@use-voltra/android-client` from pre-render files.
## Recommended migration order
1. Replace package installs.
2. Swap the Expo plugin to the platform-specific package.
3. Move JSX imports to `@use-voltra/ios` or `@use-voltra/android`.
4. Move runtime APIs to the matching `*-client` package.
5. Move server rendering code to `@use-voltra/ios-server` or `@use-voltra/android-server`.
6. Rename Android widget APIs that became platform-specific.
7. Verify `initialStatePath` files only import from platform packages.
## After migrating
Re-run prebuild for any platform whose plugin configuration changed:
```bash
npx expo prebuild --platform ios
npx expo prebuild --platform android
```
---
url: /getting-started/prior-art.md
---
# Prior Art
Voltra wouldn't be possible without the incredible work of the open-source community. This page acknowledges the libraries and projects that inspired and informed our approach to bridging JavaScript and native iOS Live Activities.
## Inspiration
### Dynamic UI
The core concept of describing SwiftUI layouts through JSON configuration was pioneered by Wesley de Groot's [Dynamic UI](https://github.com/0xWDG/DynamicUI).
### Expo Live Activity
The insight of combining JavaScript-driven development with iOS Live Activities came from Software Mansion's [Expo Live Activity](https://github.com/software-mansion-labs/expo-live-activity). Referencing their open-source Expo config plugin logic was helpful during early development.
## Thank You
We extend our gratitude to the following organizations and individuals:
- **Wesley de Groot** for creating Dynamic UI and demonstrating the power of JSON-driven SwiftUI interfaces.
- **Software Mansion** for open-sourcing their Expo Live Activity library.
- **Expo** for building Expo Modules that power Voltra behind the scenes.
- **The broader open-source community** for all the libraries, tools, and ideas that make innovative projects like Voltra possible in the first place.
Voltra is our contribution back to the broader React Native community. We hope it helps developers create more engaging and interactive Live Activities and Widgets for their iOS applications.
***
_If you're working on something that builds upon Voltra, we'd love to hear about it! Feel free to reach out and let us know how you're using it._
---
url: /getting-started/react-native-cli.md
---
# React Native CLI Projects
:::warning Experimental
Voltra support for React Native CLI projects is experimental. Feedback is welcome in [GitHub issues](https://github.com/callstackincubator/voltra/issues).
:::
Voltra fully supports React Native CLI projects through the `voltra` CLI. Instead of relying on Expo config plugins, `voltra apply` updates the native project for you: it modifies the files Voltra needs, generates new Voltra-owned files, and cleans up outdated generated files from previous runs.
## Installation
Install the same native Voltra packages you would use in Expo for the platforms you support, then add `voltra` as a dev dependency.
### iOS
```sh [npm]
npm install @use-voltra/ios @use-voltra/ios-client
```
```sh [yarn]
yarn add @use-voltra/ios @use-voltra/ios-client
```
```sh [pnpm]
pnpm add @use-voltra/ios @use-voltra/ios-client
```
```sh [bun]
bun add @use-voltra/ios @use-voltra/ios-client
```
```sh [deno]
deno add npm:@use-voltra/ios npm:@use-voltra/ios-client
```
### Android
```sh [npm]
npm install @use-voltra/android @use-voltra/android-client
```
```sh [yarn]
yarn add @use-voltra/android @use-voltra/android-client
```
```sh [pnpm]
pnpm add @use-voltra/android @use-voltra/android-client
```
```sh [bun]
bun add @use-voltra/android @use-voltra/android-client
```
```sh [deno]
deno add npm:@use-voltra/android npm:@use-voltra/android-client
```
### Voltra CLI
```sh [npm]
npm install --save-dev voltra
```
```sh [yarn]
yarn add --save-dev voltra
```
```sh [pnpm]
pnpm add --save-dev voltra
```
```sh [bun]
bun add --save-dev voltra
```
```sh [deno]
deno add --save-dev npm:voltra
```
Then create a `voltra.config.ts` file. Most properties under `ios` and `android` are the same as the Expo config equivalents, so use the existing platform docs for the shared configuration:
- [iOS Expo config options](/ios/api/plugin-configuration.md)
- [Android Expo config options](/android/api/plugin-configuration.md)
Minimal example:
```ts
import type { VoltraConfig } from 'voltra'
const config: VoltraConfig = {
ios: {
// Commonly needed on iOS so the app and extension can share data.
groupIdentifier: 'group.com.example.app',
widgets: [
{
// Basic widget identity fields.
id: 'portfolio',
displayName: 'Portfolio',
description: 'Track holdings',
// Optional, but commonly used for build-time initial widget content.
initialStatePath: './widgets/portfolio.ios.tsx',
},
],
},
android: {
widgets: [
{
id: 'portfolio',
displayName: 'Portfolio',
description: 'Track holdings',
// Required on Android.
targetCellWidth: 2,
targetCellHeight: 2,
initialStatePath: './widgets/portfolio.android.tsx',
},
],
},
}
export default config
```
Once the config is in place, apply the native project changes:
```sh [npx]
npx voltra apply
```
```sh [yarn]
yarn voltra apply
```
```sh [pnpm]
pnpm voltra apply
```
```sh [bunx]
bunx voltra apply
```
## Configuration
Most properties under `ios` and `android` use the same names as the Expo config equivalents:
- [iOS Expo config options](/ios/api/plugin-configuration.md)
- [Android Expo config options](/android/api/plugin-configuration.md)
The properties below are specific to the Voltra CLI:
| Property | Purpose |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `projectRoot` | Overrides the root directory Voltra should treat as the native project root. Useful when the config file does not live at the app root. |
| `ios.userImagesPath` | Overrides the directory for user-provided iOS widget images. |
| `ios.project` | Lets you override iOS project discovery when the app does not use the standard React Native layout. Supports fields such as `rootDir`, `xcodeprojPath`, `mainTargetName`, `infoPlistPath`, `entitlementsPath`, and `podfilePath`. |
| `android.userImagesPath` | Overrides the directory for user-provided Android widget images. |
| `android.project` | Lets you override Android project discovery when the app does not use the standard React Native layout. Supports fields such as `rootDir`, `appModuleName`, `manifestPath`, and `packageName`. |
For the full config shape, see [`VoltraConfig` and related types in source](https://github.com/callstackincubator/voltra/blob/main/packages/cli/src/config/types.ts).
## Using Voltra CLI
Every time you change `voltra.config.ts`, reapply the native project changes:
```sh [npx]
npx voltra apply
```
```sh [yarn]
yarn voltra apply
```
```sh [pnpm]
pnpm voltra apply
```
```sh [bunx]
bunx voltra apply
```
The JSX APIs and runtime APIs are the same as in the rest of the Voltra docs. Once `voltra apply` has set up your native project, continue with the platform guides for [iOS](/ios/introduction.md) and [Android](/android/introduction.md).
---
url: /index.md
---
# Live Activities & Widgets in React
> Voltra lets React Native developers build native Live Activities and widgets on iOS and Android using React components — no Swift or Kotlin required. It supports hot reload, push updates on iOS, and a config plugin that wires everything automatically.
[Get Started](/getting-started/introduction)
## Features
- **Native Primitives in JSX**: Compose native interfaces using SwiftUI (iOS) and Jetpack Compose Glance (Android) primitives directly in JSX.
- **Live Activities & Widgets**: Build Dynamic Island experiences and Live Activities for iOS, plus Home Screen widgets for both iOS and Android using a unified React workflow.
- **Push-to-update for Live Activities**: Stream real-time updates to Live Activities via push notifications from any JavaScript runtime. Keep activities current without app interaction.
---
url: /ios/api/configuration.md
---
# Configuration
Voltra provides several configuration options to control Live Activity behavior, lifecycle, and appearance. These options can be used when starting, updating, or stopping Live Activities.
For Expo plugin configuration options (like `groupIdentifier`, `enablePushNotifications`, `deploymentTarget`, and `widgets`), see the [Plugin Configuration](/ios/api/plugin-configuration.md) documentation.
## Dismissal Policy
Voltra supports configuring how Live Activities behave after they end. You can control the dismissal timing using the `dismissalPolicy` option:
### Dismissal Policy Options
- **`'immediate'`** (default): The Live Activity is dismissed immediately when it ends
- **`{ after: number }`**: The Live Activity remains visible for the specified number of seconds after ending, then automatically dismisses
### Usage Examples
**Immediate dismissal (default behavior):**
```typescript
import { startLiveActivity } from '@use-voltra/ios-client'
await startLiveActivity(variants, {
dismissalPolicy: 'immediate', // or omit for default
})
```
**Delayed dismissal (keep visible for 30 seconds after ending):**
```typescript
await startLiveActivity(variants, {
dismissalPolicy: { after: 30 },
})
```
**Update dismissal policy for active Live Activities:**
```typescript
import { updateLiveActivity } from '@use-voltra/ios-client'
await updateLiveActivity(activityId, variants, {
dismissalPolicy: { after: 60 },
})
```
**Set dismissal policy when ending a Live Activity:**
```typescript
import { stopLiveActivity } from '@use-voltra/ios-client'
await stopLiveActivity(activityId, {
dismissalPolicy: { after: 10 },
})
```
The dismissal policy applies to both programmatic ending (`stopLiveActivity`) and natural ending (when timers reach their end time). This gives you fine-grained control over the user experience when Live Activities conclude.
## Additional Configuration Options
Voltra provides additional configuration options to control Live Activity behavior and appearance.
### Stale Date
The `staleDate` option allows you to specify when a Live Activity should be considered stale and automatically dismissed by the system.
```typescript
import { startLiveActivity } from '@use-voltra/ios-client'
// Dismiss the Live Activity after 1 hour
await startLiveActivity(variants, {
staleDate: Date.now() + 60 * 60 * 1000, // 1 hour from now
})
```
**Note:** If you provide a `staleDate` in the past, it will be ignored and the Live Activity will use default behavior.
### Relevance Score
The `relevanceScore` option helps iOS prioritize which Live Activities to display when space is limited. Higher scores (closer to 1.0) indicate more important activities.
```typescript
import { startLiveActivity } from '@use-voltra/ios-client'
// High priority Live Activity (e.g., active delivery)
await startLiveActivity(variants, {
relevanceScore: 0.8,
})
// Low priority Live Activity (e.g., background task)
await startLiveActivity(variants, {
relevanceScore: 0.2,
})
```
**Valid range:** 0.0 to 1.0 (default: 0.0)
### Channel ID (broadcast push, iOS 18+)
The `channelId` option subscribes the Live Activity to a broadcast channel for server-side updates. When provided, the activity receives updates via broadcast push notifications instead of individual device tokens—one server notification updates all activities on that channel. Requires `enablePushNotifications: true` and the Broadcast Capability enabled in your Apple Developer account.
```typescript
import { startLiveActivity } from '@use-voltra/ios-client'
await startLiveActivity(variants, {
activityName: 'match-123',
channelId: 'CTrNsYq/Ee8AALLzHQaVlA==', // From APNs channel management
})
```
For full broadcast setup, see [Server-side updates - Broadcast push notifications](/ios/development/server-side-updates.md#broadcast-push-notifications-ios-18).
These options can be used together with dismissal policy and other configuration options:
```typescript
await startLiveActivity(variants, {
dismissalPolicy: { after: 30 },
staleDate: Date.now() + 2 * 60 * 60 * 1000, // 2 hours
relevanceScore: 0.7,
})
```
---
url: /ios/api/plugin-configuration.md
---
# Plugin configuration
The Voltra Expo config plugin accepts several configuration options in your `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.your.bundle.identifier",
"enablePushNotifications": true,
"deploymentTarget": "18.0",
"targetName": "MyAppLiveActivity",
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Shows current weather conditions",
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/weather-initial.tsx"
}
]
}
]
]
}
}
```
## Configuration options
### `groupIdentifier` (optional)
App Group identifier for sharing data between your app and the widget extension. Required if you want to:
- Forward component events (like button taps) from Live Activities to your JavaScript code
- Share images between your app and the extension
- Use image preloading features
**Format:** Must start with `group.` (e.g., `group.your.bundle.identifier`)
### `enablePushNotifications` (optional)
Enable server-side updates for Live Activities via Apple Push Notification Service (APNS). When enabled, you can update Live Activities even when your app is in the background or terminated.
**Type:** `boolean`\
**Default:** `false`
### `deploymentTarget` (optional)
iOS deployment target version for the widget extension. If not provided, defaults to `17.0`. This allows the widget extension to have its own deployment target independent of the main app.
**Type:** `string`
**Default:** `"17.0"`
**Example:** `"18.0"`
**Note:** Code signing settings (development team, provisioning profiles) are automatically synchronized from the main app target, but the deployment target can be set independently.
### `targetName` (optional)
Custom target name for the widget extension. If not provided, defaults to `{AppName}LiveActivity` where `AppName` is your app's sanitized name.
This is useful when:
- Migrating from other Live Activity solutions (e.g., `@bacons/apple-targets`)
- Matching existing provisioning profiles or credentials
- Using a specific naming convention for your organization
**Type:** `string`
**Default:** `"{AppName}LiveActivity"`
**Example:** `"widget"`, `"MyAppLiveActivity"`
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.your.bundle.identifier",
"targetName": "widget"
}
]
]
}
}
```
### `widgets` (optional)
Array of widget configurations for Home Screen widgets. Each widget will be available in the iOS widget gallery.
**Widget Configuration Properties:**
- `id`: Unique identifier for the widget (alphanumeric with underscores only)
- `displayName`: Name shown in the widget gallery (plain string, or per-locale map like `{ "en": "Weather", "pl": "Pogoda" }`; locale keys are BCP‑47-style tags)
- `description`: Description shown in the widget gallery (same localization rules as `displayName`)
- `supportedFamilies`: Array of supported widget sizes (`systemSmall`, `systemMedium`, `systemLarge`)
- `initialStatePath`: (optional) Project-relative path to a file that exports initial widget state, **or** a locale map of paths for localized build-time pre-rendering (see [Widget Pre-rendering](/ios/development/widget-pre-rendering.md))
- `serverUpdate`: (optional) Enable server-driven updates. See [Server-driven widgets](/ios/development/server-driven-widgets.md) for full details.
- `url`: The Voltra SSR endpoint URL
- `intervalMinutes`: Update interval in minutes (default: `15`)
- `refresh`: Show a native refresh button (default: `false`, requires iOS 17+)
**Example:**
```json
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Current weather conditions",
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": {
"en": "./widgets/weather-initial.tsx",
"pl": "./widgets/weather-initial-pl.tsx"
},
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 30,
"refresh": true
}
}
]
}
```
### Localizing `displayName` and `description`
Use a locale map when the widget gallery label should be translated:
```json
{
"widgets": [
{
"id": "weather",
"displayName": {
"en": "Weather",
"pl": "Pogoda",
"zh-Hans": "天气"
},
"description": {
"en": "Current weather conditions",
"pl": "Aktualne warunki pogodowe",
"zh-Hans": "当前天气状况"
}
}
]
}
```
Use BCP-47-style locale tags such as `en`, `en-US`, `pt-BR`, or `zh-Hans`.
Fallback behavior:
- Voltra first tries the device locale.
- If there is no exact match, it falls back to the language-only match.
- If there is still no match, it prefers an English locale such as `en` or `en-US`.
- If no English entry exists, it uses the first configured locale.
---
url: /ios/charts.md
---
# Charts (iOS)
Use charts in Live Activities and widgets to show trends, comparisons, progress, or composition at a glance. You can mix bars, lines, areas, points, rules, and sectors in a single chart.
:::info
Charts require iOS 16.0+. SectorMark (pie/donut) requires iOS 17.0+.
:::
:::warning
Mark components (`BarMark`, `LineMark`, etc.) must be **direct children** of ``. They cannot be wrapped in custom components. For example, this will not work:
```tsx
// This won't work — marks are not direct children
function MyMarks() {
return
}
```
Instead, always place marks directly inside Chart:
```tsx
```
:::
## Basic Usage
Wrap one or more mark components inside ``:
```tsx
```
## Data Types
All marks except `RuleMark` take a `data` prop. There are two data shapes depending on the mark type:
```typescript
// BarMark, LineMark, AreaMark, PointMark
type ChartDataPoint = {
x: string | number // Categorical ("Jan") or numeric (42)
y: number
series?: string // Optional — groups data for multi-series charts
}
// SectorMark
type SectorDataPoint = {
value: number // Proportional angular value
category: string // Sector label
}
```
## Marks
### BarMark
Use bars when people need to compare values across categories.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Fill color.
- `cornerRadius` (number, optional): Rounded bar corners.
- `width` (number, optional): Fixed bar width.
- `stacking` (string, optional): `"grouped"` (side by side).
```tsx
```
***
### LineMark
Use a line when the shape of change matters more than individual columns.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Line color.
- `lineWidth` (number, optional): Stroke width in points.
- `interpolation` (string, optional): Curve type — `"linear"`, `"monotone"`, `"catmullRom"`, `"cardinal"`, `"stepStart"`, `"stepCenter"`, or `"stepEnd"`.
- `symbol` (string, optional): Symbol at each point — `"circle"`, `"square"`, `"triangle"`, `"diamond"`, `"pentagon"`, `"cross"`, `"plus"`, or `"asterisk"`.
```tsx
```
***
### AreaMark
Use an area chart when you want the overall volume or rise/fall pattern to read quickly.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Fill color.
- `interpolation` (string, optional): Same options as LineMark.
```tsx
```
***
### PointMark
Use points for sparse measurements, scatter plots, or to emphasize exact observations.
**Parameters:**
- `data` (ChartDataPoint\[], required): The data points.
- `color` (string, optional): Point color.
- `symbol` (string, optional): Same options as LineMark.
- `symbolSize` (number, optional): Symbol size in points.
```tsx
```
***
### RuleMark
A horizontal or vertical reference line. Unlike other marks, RuleMark has no `data` array.
**Parameters:**
- `yValue` (number, optional): Draw a horizontal line at this y value.
- `xValue` (string | number, optional): Draw a vertical line at this x value.
- `color` (string, optional): Line color.
- `lineWidth` (number, optional): Stroke width in points.
If both `xValue` and `yValue` are provided, Voltra renders both lines.
```tsx
```
***
### SectorMark
Pie and donut charts. Requires iOS 17+.
**Parameters:**
- `data` (SectorDataPoint\[], required): Sector data with `value` and `category`.
- `color` (string, optional): Fill color (overrides automatic coloring).
- `innerRadius` (number, optional): Ratio from 0 to 1. `0` = pie chart, any value above `0` = donut chart.
- `outerRadius` (number, optional): Ratio from 0 to 1.
- `angularInset` (number, optional): Gap between sectors in degrees.
```tsx
// Pie chart
// Donut chart
```
## Chart Props
The `` container accepts these props in addition to the standard Voltra `style` prop:
| Prop | Type | Description |
| ---------------------- | ---------------------------------------------------------------------------- | -------------------------- |
| `xAxisVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the x-axis |
| `xAxisGridStyle` | `{ visible?: boolean; color?: string; lineWidth?: number; dash?: number[] }` | Control x-axis grid lines |
| `yAxisVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the y-axis |
| `yAxisGridStyle` | `{ visible?: boolean; color?: string; lineWidth?: number; dash?: number[] }` | Control y-axis grid lines |
| `legendVisibility` | `"automatic" \| "visible" \| "hidden"` | Show or hide the legend |
| `foregroundStyleScale` | `Record` | Map series names to colors |
## Grid Lines
Use `xAxisGridStyle` and `yAxisGridStyle` when the default grid is too busy or not visible enough for your design:
```tsx
```
## Multi-Series Charts
Add a `series` field to your data points when you want multiple datasets in the same chart. Use `foregroundStyleScale` to keep those series colors consistent:
```tsx
```
Without `foregroundStyleScale`, colors are chosen automatically.
For side-by-side bars, set `stacking="grouped"`:
```tsx
```
## Combining Marks
Mix mark types when one chart needs both context and emphasis, such as bars for totals plus a rule for a target:
```tsx
```
## Sparkline / Minimal Style
Hide axes and legend for a clean, compact visualization:
```tsx
```
## Widget / Live Activity Notes
Charts in widgets and Live Activities are static. Focus on a clear snapshot of the data rather than interactions like panning or scrolling.
The `` component supports the full Voltra style system on its container — padding, background, borders, corner radius, shadows, and sizing all work:
```tsx
```
---
url: /ios/components/interactive.md
---
# Interactive Controls (iOS)
User interface controls that respond to user interaction in Live Activities.
***
## Button
An interactive button component that triggers in-app events via interaction intents.
**Parameters:**
- `buttonStyle` (string, optional): Visual style of the button:
- `"automatic"` - System-determined style
- `"bordered"` - Bordered style
- `"borderedProminent"` - Bordered with prominent fill
- `"plain"` - Plain style without border
- `"borderless"` - Borderless style
**Apple Documentation:** [Button](https://developer.apple.com/documentation/swiftui/button)
**Availability:** iOS 17.0+ (interaction intents)
### Usage
Buttons fire interaction events that you can handle in your app:
```tsx
Play Music
```
Handle the event:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('interaction', (event) => {
if (event.identifier === 'play-button') {
// Handle play action
}
})
```
### Examples
**Styled button:**
```tsx
Save Changes
```
**Button with icon:**
```tsx
Delete
```
**Compact button:**
```tsx
```
***
## Link
A navigable link component that opens a URL when tapped. Uses SwiftUI's native Link for semantic navigation.
**Parameters:**
- `destination` (string, required): URL to navigate to when tapped. Supports both absolute URLs and relative paths.
**Apple Documentation:** [Link](https://developer.apple.com/documentation/swiftui/link)
**Availability:** iOS 14.0+
### URL Normalization
Link automatically normalizes URLs using your app's URL scheme:
- Absolute URLs: Used as-is (`"myapp://orders/123"`, `"https://example.com"`)
- Relative with `/`: `"/settings"` → `"myapp://settings"`
- Relative without `/`: `"help"` → `"myapp://help"`
### Examples
**Link with absolute URL:**
```tsx
Order #123Tap to view details
```
**Link with relative path:**
```tsx
Open Settings
```
**External link:**
```tsx
Visit Support Site
```
### When to use Link vs Button
| Feature | Link | Button |
| ---------------- | ------------------------------ | ------------------------------------------ |
| **Use Case** | Navigation to URLs | In-app actions/events |
| **Visual** | Unstyled (custom via children) | Button styling (bordered, prominent, etc.) |
| **iOS Version** | 14.0+ | 17.0+ |
| **Tap Behavior** | Opens URL | Fires interaction event |
| **Mechanism** | SwiftUI Link | AppIntents (VoltraInteractionIntent) |
**Recommendation:** Use `Link` for navigation (e.g., list items, cards that open URLs). Use `Button` for actions that your app needs to handle (e.g., play/pause, save, delete).
***
## Toggle
Toggles a boolean state via an intent. Fires an interaction event when changed.
**Parameters:**
- `defaultValue` (boolean, optional): Initial toggle state (default: `false`)
**Apple Documentation:** [Toggle](https://developer.apple.com/documentation/swiftui/toggle)
**Availability:** iOS 17.0+
**Example:**
```tsx
```
Handle toggle events:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('interaction', (event) => {
if (event.identifier === 'notifications-toggle') {
// Handle toggle state change
}
})
```
---
url: /ios/components/layout.md
---
# Layout & Containers (iOS)
Components that arrange other elements or provide structural grouping.
## Alignment & Positioning
Voltra uses SwiftUI's native positioning model. Instead of CSS-style `position: absolute` with `top`/`left`/`right`/`bottom`, you use:
1. **Stack `alignment` props** - Position children within their container
2. **`offsetX`/`offsetY` styles** - Fine-tune individual element positions
Each stack type has different alignment options based on its layout direction.
***
### VStack
A vertical stack container that arranges its children in a column.
**Parameters:**
- `spacing` (number, optional): Spacing between children in points
- `alignment` (string, optional): Horizontal alignment of children:
- `"leading"` - Align to left edge
- `"center"` (default) - Align to center
- `"trailing"` - Align to right edge
**Apple Documentation:** [VStack](https://developer.apple.com/documentation/swiftui/vstack)
***
### HStack
A horizontal stack container that arranges its children in a row.
**Parameters:**
- `spacing` (number, optional): Spacing between children in points
- `alignment` (string, optional): Vertical alignment of children:
- `"top"` - Align to top edge
- `"center"` (default) - Align to center
- `"bottom"` - Align to bottom edge
- `"firstTextBaseline"` - Align to first text baseline
- `"lastTextBaseline"` - Align to last text baseline
**Apple Documentation:** [HStack](https://developer.apple.com/documentation/swiftui/hstack)
***
### ZStack
A depth-based stack container that overlays its children on top of each other. Use ZStack when you need to layer elements, such as placing a badge over an image.
**Parameters:**
- `alignment` (string, optional): Positions ALL children at the specified alignment point. Available values:
- `"center"` (default) - Center of the stack
- `"leading"` - Left edge (or right in RTL)
- `"trailing"` - Right edge (or left in RTL)
- `"top"` - Top edge
- `"bottom"` - Bottom edge
- `"topLeading"` - Top-left corner
- `"topTrailing"` - Top-right corner
- `"bottomLeading"` - Bottom-left corner
- `"bottomTrailing"` - Bottom-right corner
**Apple Documentation:** [ZStack](https://developer.apple.com/documentation/swiftui/zstack)
#### Positioning with ZStack
In SwiftUI (and Voltra), positioning works differently than CSS. The `alignment` prop on ZStack positions **all children** at the same alignment point. The ZStack's size is determined by its largest child.
**Example: Badge overlay**
```tsx
{/* Image defines the ZStack size */}
{/* Badge is positioned at top-right, then nudged with offset */}
3
```
:::tip
Use `offsetX` and `offsetY` style properties to fine-tune individual element positions after alignment. Positive `offsetX` moves right, positive `offsetY` moves down.
:::
***
### View
A flexible container component that **always uses flexbox layout**. Unlike VStack and HStack which use native SwiftUI stacks by default, View is specifically designed for React Native-style flexbox layouts.
:::tip Flexbox-First Component
The View component is purpose-built for flexbox layouts and always uses the flexbox layout engine. See the [Flexbox Layout](/ios/development/flexbox-layout.md) guide for comprehensive documentation.
:::
**Style Properties:**
View responds to flexbox style properties set via the `style` prop:
- `flexDirection`: `'row'` | `'column'` (default: `'column'`)
- `alignItems`: `'flex-start'` | `'center'` | `'flex-end'` | `'stretch'`
- `justifyContent`: `'flex-start'` | `'center'` | `'flex-end'` | `'space-between'` | `'space-around'` | `'space-evenly'`
- `gap`: Spacing between children in points
**Example:**
```tsx
// Horizontal layout with space between
LeftCenterRight
// Vertical layout with centered items
Success
```
**When to use View:**
- You need React Native-style flexbox behavior
- You want dynamic `flexDirection` (switching between row/column)
- You need `justifyContent` spacing modes
**When to use VStack/HStack:**
- Simple vertical or horizontal layouts
- You want SwiftUI's native stack performance
- You need SwiftUI-specific alignment (like firstTextBaseline)
**Availability:** iOS 16.0+
**Learn More:** [Flexbox Layout Guide](/ios/development/flexbox-layout.md)
***
### Spacer
A flexible space component that expands to fill available space in its container.
**Parameters:**
- `minLength` (number, optional): Minimum length
**Apple Documentation:** [Spacer](https://developer.apple.com/documentation/swiftui/spacer)
***
### GroupBox
A grouped content container that visually groups related content with a styled background.
**Parameters:** None
**Apple Documentation:** [GroupBox](https://developer.apple.com/documentation/swiftui/groupbox)
***
### GlassContainer
A Liquid Glass container that wraps Apple's [`GlassEffectContainer`](https://developer.apple.com/documentation/swiftui/glasseffectcontainer) to provide a modern glassmorphism effect for grouping content.
:::warning iOS 26 SDK Required
This component uses Apple's `GlassEffectContainer` API which requires **Xcode with iOS 26 SDK** to build. If you're using an older Xcode version, you'll encounter build errors:
```
- value of type 'some View' has no member 'glassEffect'
- cannot find 'GlassEffectContainer' in scope
```
**Workaround:** Avoid using this component until iOS 26 SDK is available in your Xcode version. At runtime, devices with iOS \< 26 will gracefully fall back to a regular container without the glass effect.
:::
**Parameters:**
- `spacing` (number, optional): Spacing between glass elements
**Availability:**
- **Build:** Requires Xcode with iOS 26 SDK (uses `GlassEffectContainer` API)
- **Runtime:** iOS 26+ for glass effect, graceful fallback on earlier versions
**Apple Documentation:** [GlassEffectContainer](https://developer.apple.com/documentation/swiftui/glasseffectcontainer)
---
url: /ios/components/overview.md
---
# Components Overview (iOS)
Voltra provides SwiftUI primitives with JSX bindings, allowing developers to create rich, interactive Live Activities using React/JSX syntax. These components connect web development workflows with native iOS Live Activity rendering.
## Getting Started
Import iOS primitives from `@use-voltra/ios` and use the `Voltra` namespace:
```tsx
import { Voltra } from '@use-voltra/ios'
const MyComponent = () => {
// Use any component
return (
Hello Live Activity!Tap me
)
}
```
## Component Categories
Voltra organizes its components into categories:
### Layout & Containers
Components that arrange other elements or provide structural grouping. These include stacks (VStack, HStack, ZStack), spacers, and container components like GroupBox and GlassContainer.
[See all layout & container components →](/ios/components/layout.md)
### Visual Elements & Typography
Static or decorative elements used to display content. This category includes Text, Label, Image, Symbol, and visual effects like LinearGradient, Mask, and Divider.
[See all visual elements & typography components →](/ios/components/visual.md)
### Data Visualization & Status
Components for displaying data and status information. This includes progress indicators (LinearProgressView, CircularProgressView), gauges, and timers for showing dynamic information in Live Activities.
[See all data visualization & status components →](/ios/components/status.md)
### Interactive Controls & Navigation
User interface controls that respond to user interaction and navigation. This category includes Button and Toggle components for interactive Live Activities, plus Link for semantic URL navigation.
[See all interactive control & navigation components →](/ios/components/interactive.md)
---
url: /ios/components/status.md
---
# Data Visualization & Status (iOS)
Components specifically designed to show dynamic values or states over time in Live Activities and Widgets.
### LinearProgressView
A horizontal progress bar that displays determinate progress or timer-based progress.
#### Limitations
- When using `timerInterval` for smooth animations, custom styling properties such as `height`, `trackColor`, `cornerRadius`, and the `thumb` component are ignored. The component will use the default system appearance in this mode.
***
### CircularProgressView
A circular progress indicator that displays determinate progress or timer-based progress.
#### Limitations
- While `timerInterval` is supported, the progress ring will not animate continuously. It only updates its visual state when the component state is refreshed. For a smooth, live-animating progress bar, use `LinearProgressView`.
***
### Gauge
A gauge indicator for progress visualization (iOS 16+).
***
### Timer
A flexible component for displaying live-updating time intervals. Crucial for Live Activities, it uses native SwiftUI text interpolation to ensure the time updates automatically on the lock screen and in the Dynamic Island without requiring background updates from React Native.
**Modes:**
- **Timer Mode:** For fixed intervals (countdowns or counting up to a target). Requires `endAtMs` or `durationMs`.
- **Stopwatch Mode:** For open-ended intervals counting up from a starting point. Requires `startAtMs` and `direction="up"`, but both `endAtMs` and `durationMs` must be omitted.
**Parameters:**
- `startAtMs` (number, optional): Start time in milliseconds since epoch.
- `endAtMs` (number, optional): End time in milliseconds since epoch.
- `durationMs` (number, optional): Duration in milliseconds. Used if `endAtMs` is omitted.
- `direction` (string, optional): Count direction. Can be `'up'` or `'down'`. Defaults to `'down'`.
- `textStyle` (string, optional): Formatting style.
- `'timer'`: Standard clock format (e.g., `05:00`).
- `'relative'`: Relative format (e.g., `5m`).
- `showHours` (boolean, optional): Whether to show hours (e.g., `1:30:00` vs `90:00`). Defaults to `false`.
- `textTemplates` (string, optional): JSON-encoded object with `running` and `completed` templates. Use `{time}` as a placeholder.
**Examples:**
```tsx
// Timer Mode: Countdown 5 minutes
// Stopwatch Mode: Count up indefinitely from now
// Relative Timer with Template
```
---
url: /ios/components/visual.md
---
# Visual Elements & Typography (iOS)
Static or decorative elements used to display content.
### Text
Displays text content.
**Parameters:**
- `numberOfLines` (number, optional): Maximum number of lines to display
***
### Label
A semantic label that can display both an icon and title text.
**Parameters:**
- `title` (string, optional): Text content for the label
- `systemImage` (string, optional): SF Symbol name for the label icon
***
### Image
Displays bitmap images from the asset catalog or base64 encoded data.
**Parameters:**
- `source` (object, optional): Image source object (`assetName` or `base64`)
- `resizeMode` (string, optional): `"cover"`, `"contain"`, `"stretch"`, `"repeat"`, or `"center"`
- `fallback` (ReactNode, optional): Custom content rendered when the image is missing
**Styling the fallback:**
To add a background color when an image is missing, use `backgroundColor` in the `style` prop:
```jsx
```
***
### Symbol
Displays SF Symbols (system icons) with configuration options.
***
### Divider
A visual divider component.
***
### LinearGradient
A linear gradient background that can contain children.
***
### Mask
Masks content using any Voltra element as the mask shape.
---
url: /ios/development/configurable-widgets.md
---
# Configurable Widgets
:::warning Experimental Feature
Configurable widgets are experimental. Please [report any issues](https://github.com/callstackincubator/voltra/issues) you find.
:::
Configurable widgets let users edit widget parameters in the native iOS Edit Widget sheet. Use them when a Dynamic Widget needs a few user-editable knobs, such as a label, unit, theme, or source.
It requires iOS 17+, because Voltra wires it through `AppIntentConfiguration`.
## How it works
1. Define a Dynamic Widget module with a default export.
2. Add `entry` and `appIntent.parameters` to the widget config in `app.json`.
3. Read the selected values in your widget.
4. Let the user edit the widget from the iOS widget sheet.
Each parameter has:
- `name`: key that appears in `env.configuration`
- `title`: label shown in the Edit Widget sheet
- `default`: code-defined starting value before the user changes anything
## How to use it
```tsx
import { Voltra, type WidgetEnvironment } from '@use-voltra/ios'
type GreetingConfig = { label?: string }
export default function GreetingWidget(
_props: object,
env: WidgetEnvironment = {} as WidgetEnvironment
) {
const label = env.configuration?.label ?? 'Hello'
return (
{label}
Edit me from the widget sheet.
)
}
```
Plugin config:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"widgets": [
{
"id": "greeting_widget",
"entry": "./widgets/ios/greeting-widget.tsx",
"displayName": "Greeting Widget",
"description": "A Dynamic Widget with user-editable parameters",
"supportedFamilies": ["systemSmall", "systemMedium"],
"initialStatePath": "./widgets/ios/greeting-widget.tsx",
"appIntent": {
"parameters": [
{
"name": "label",
"title": "Label",
"default": "Hello"
}
]
}
}
]
}
]
]
}
}
```
## Using it in app
1. Build and install the app on a real iPhone.
2. Add the widget to Home Screen.
3. Long-press it and tap **Edit Widget**.
4. Change parameters.
5. Read values from `env.configuration` in your JSX.
If you need more than one value, add more entries to `appIntent.parameters` and read each key from `env.configuration`.
## Notes
- `appIntent` only wires up for Dynamic Widgets.
- Defaults come from code, not from the native sheet.
- There is no `export` field in app.json for Dynamic Widgets.
- Use a real device to verify the Edit Widget flow.
---
url: /ios/development/developing-live-activities.md
---
# Developing Live Activities
Voltra provides APIs that make building and testing Live Activities easier during development.
## Supported variants
Live Activities in iOS can appear in different contexts, and Voltra supports defining UI variants for each of these contexts. For detailed information about Live Activity design guidelines, see the [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/live-activities).
### Lock Screen
The `lockScreen` variant defines how your Live Activity appears on the lock screen. It can be either a ReactNode directly, or an object with content and optional styling:
```typescript
const variants = {
lockScreen: (
Your content here
),
}
```
To customize the system Lock Screen chrome, pass an object with `content` and `activityBackgroundTint`:
```typescript
const variants = {
lockScreen: {
activityBackgroundTint: '#101828',
content: (
Your content here
),
},
}
```
`activityBackgroundTint` is applied via SwiftUI's `activityBackgroundTint(...)` modifier on iOS. Voltra currently accepts the same color formats handled by the native iOS parser:
- Hex colors such as `#RGB`, `#RGBA`, `#RRGGBB`, and `#RRGGBBAA`
- `rgb(...)` and `rgba(...)`
- `hsl(...)` and `hsla(...)`
- Named colors: `red`, `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `white`, `gray`, `black`, `clear`, `transparent`, `primary`, `secondary`
Use `clear` or `transparent` to make the Live Activity background transparent.
If the string cannot be parsed as one of those formats, iOS ignores the tint.
### Dynamic Island
The `island` variant defines how your Live Activity appears in the Dynamic Island (available on iPhone 14 Pro and later). The Dynamic Island has three display states:
- **Minimal**: A compact pill-shaped view that appears when the activity is in the background
- **Compact**: A slightly larger view with leading and trailing regions
- **Expanded**: A full-width view with center, leading, trailing, and bottom regions
```typescript
const variants = {
island: {
keylineTint: '#10B981', // Optional tint color for the Dynamic Island keyline
minimal: ,
compact: {
leading: Order,
trailing: Confirmed,
},
expanded: {
center: Order Confirmed,
leading: ,
trailing: ETA: 15 min,
bottom: Your order is being prepared,
},
},
}
```
`keylineTint` uses the same iOS color parser and accepted formats as `activityBackgroundTint`.
### Supplemental Activity Families (iOS 18+, watchOS 11+)
The `supplementalActivityFamilies` variant defines how your Live Activity appears on Apple Watch Smart Stack and CarPlay displays. This variant is optional and works seamlessly with your existing lock screen and Dynamic Island variants.
```typescript
const variants = {
lockScreen: (
{/* iPhone lock screen content */}
),
island: {
/* Dynamic Island variants for iPhone */
},
supplementalActivityFamilies: {
small: (
12 minETA
),
},
}
```
If `supplementalActivityFamilies.small` is not provided, Voltra will automatically construct it from your Dynamic Island `compact` variant by combining the leading and trailing content in an HStack.
## Limitations
### Animations and Live Updates
There are specific constraints on how content can animate or update:
- **Continuous Animations**: Custom continuous animations, such as rotating icons or elements moving along a path, are not supported.
- **Smooth Updates**: Per-second "live" updates are only supported by specific components designed for this purpose:
- `Timer`: For countdowns and stopwatches.
- `LinearProgressView`: When used with `timerInterval`.
- **Styling Trade-offs**: To enable smooth, system-driven animations (like a progress bar filling up in real-time), certain components may ignore custom styling properties (e.g., custom heights or thumb components) and fallback to standard system appearances.
All other components only update their visual state when a new activity state is pushed from your application.
## useLiveActivity
For React development, Voltra provides the `useLiveActivity` hook for integration with the component lifecycle and automatic updates during development.
:::warning
Unfortunately, iOS suspends background apps after approximately 30 seconds. This means that if you navigate away from your app (for example, to check the Dynamic Island or lock screen), live reload and auto-update functionality will be paused.
:::
```typescript
import { Voltra } from '@use-voltra/ios'
import { useLiveActivity } from '@use-voltra/ios-client'
function OrderLiveActivity({ orderId, status }) {
const variants = {
lockScreen: (
{status === 'confirmed' ? 'Order Confirmed' : 'Order Ready'}
{status === 'confirmed' ? 'Your order is being prepared' : 'Your order is ready for pickup'}
{status === 'ready' && (
I'm Here
)}
),
}
const { start, update, end, isActive } = useLiveActivity(variants, {
activityName: `order-${orderId}`,
autoStart: true, // Automatically start when component mounts
autoUpdate: true, // Automatically update when variants change
deepLinkUrl: `myapp://order/${orderId}`,
})
// Manual control if needed
const handleCancelOrder = async () => {
await end()
}
return (
Live Activity: {isActive ? 'Active' : 'Inactive'}
)
}
```
## VoltraView Component
For testing and development, Voltra provides a `VoltraView` component that renders Voltra JSX components directly in your React Native app. This is useful for:
- Testing component layouts before deploying to Live Activities
- Handling user interactions in development
- Previewing how your Live Activity will look
```tsx
import { Voltra } from '@use-voltra/ios'
import { VoltraView } from '@use-voltra/ios-client'
function MyComponent() {
const handleInteraction = (event: VoltraInteractionEvent) => {
console.log('User interacted with:', event.identifier)
console.log('Payload:', event.payload)
}
return (
Test Live ActivityThis is how it will lookTest Button
)
}
```
**Props:**
- `id`: Unique identifier for the view (used for event filtering)
- `children`: Voltra JSX components to render
- `style`: React Native style for the container
- `onInteraction`: Callback for user interactions with buttons/toggles
```
**Hook Options:**
- `activityName`: Name of the Live Activity
- `autoStart`: Automatically start when component mounts
- `autoUpdate`: Automatically update when variants change
- `deepLinkUrl`: URL to open when Live Activity is tapped
**Hook Returns:**
- `start()`: Start the Live Activity
- `update()`: Update the Live Activity
- `end()`: Stop the Live Activity
- `isActive`: Boolean indicating if the Live Activity is currently active
```
---
url: /ios/development/developing-widgets.md
---
# Developing Widgets
Voltra provides APIs that make building and testing Home Screen widgets easier during development.
## VoltraWidgetPreview component
`VoltraWidgetPreview` is a **React Native component** for testing and developing Voltra widget content. It renders Voltra JSX components at the exact dimensions of specific iOS widget families. This is useful for:
- Testing component layouts before deploying to widgets
- Previewing how your widget will look across different sizes
- Developing and iterating on widget content within your React Native app
:::note
`VoltraWidgetPreview` is a regular React Native component. Use it in your React Native screens, not inside Voltra components.
:::
```tsx
import { ScrollView, View } from 'react-native'
import { Voltra } from '@use-voltra/ios'
import { VoltraWidgetPreview } from '@use-voltra/ios-client'
function MyWidgetContent() {
return (
Weather WidgetSunny, 72°F
)
}
// Preview different widget sizes in your React Native screen
function WidgetTestingScreen() {
return (
)
}
```
**Props:**
- `family`: Widget family size to preview (systemSmall, systemMedium, systemLarge, etc.)
- `style`: Additional React Native styles to apply
- `id`: Unique identifier for the view (used for event filtering)
- `onInteraction`: Callback for user interactions with buttons/toggles
## updateWidget API
The `updateWidget` function allows you to update home screen widget content with new data.
:::warning
Widget updates are throttled to around an update per minute. iOS limits how frequently widgets can be refreshed to preserve battery life and system performance.
:::
```typescript
import { Voltra } from '@use-voltra/ios'
import { updateWidget } from '@use-voltra/ios-client'
await updateWidget('weather', {
systemSmall: 72°F,
systemMedium: (
72°FSunnyHigh: 78° Low: 65°
),
systemLarge: (
Weather72°F - SunnyHigh: 78° Low: 65°
),
}, { deepLinkUrl: 'myapp://weather' })
```
**Parameters:**
- `widgetId`: The widget identifier (as defined in your config plugin)
- `variants`: An object mapping widget families to specific content
- `options.deepLinkUrl`: URL to open when the widget is tapped
## scheduleWidget API
For widgets that need to change throughout the day, `scheduleWidget` lets you batch multiple updates in advance. iOS will automatically display each entry at its scheduled time—even when your app isn't running.
:::tip
This is perfect for weather forecasts, calendar events, news rotation, or any content that changes on a predictable schedule.
:::
```typescript
import { Voltra } from '@use-voltra/ios'
import { scheduleWidget } from '@use-voltra/ios-client'
// Schedule weather updates throughout the day
await scheduleWidget('weather', [
{
date: new Date('2026-01-16T09:00:00'),
variants: {
systemSmall: Morning: 65°F ☀️,
systemMedium: Good morning! 65°F and sunny
}
},
{
date: new Date('2026-01-16T15:00:00'),
variants: {
systemSmall: Afternoon: 72°F ☀️,
systemMedium: Afternoon: 72°F and sunny
}
},
{
date: new Date('2026-01-16T21:00:00'),
variants: {
systemSmall: Evening: 68°F 🌙,
systemMedium: Good evening! 68°F and clear
}
}
])
```
**Parameters:**
- `widgetId`: The widget identifier (as defined in your config plugin)
- `entries`: Array of timeline entries, each containing:
- `date`: When this content should be displayed
- `variants`: Widget content for different size families
- `deepLinkUrl` (optional): URL to open when tapping this specific entry
**With deep links per entry:**
```typescript
await scheduleWidget('news', [
{
date: new Date('2026-01-16T08:00:00'),
variants: {
systemSmall: Morning Headlines
},
deepLinkUrl: '/news/morning'
},
{
date: new Date('2026-01-16T20:00:00'),
variants: {
systemSmall: Evening Edition
},
deepLinkUrl: '/news/evening'
}
])
```
:::warning iOS System Constraints
iOS controls when widgets actually update based on battery level, widget visibility, and system load. While you can schedule entries at any interval, iOS typically enforces a minimum of \~15 minutes between updates. Entries scheduled more frequently may be delayed or coalesced.
:::
**Best practices:**
- Schedule entries at realistic intervals (15+ minutes apart)
- Don't schedule hundreds of entries—iOS has a daily refresh budget
- Use `updateWidget` for immediate one-time updates
- Use `scheduleWidget` for predictable, recurring content changes
## Widget configuration via Expo plugin
Widgets are configured through the Voltra Expo config plugin. Add the widget configuration to your `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Current weather conditions",
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"]
}
]
}
]
]
}
}
```
**Widget Configuration Properties:**
- `id`: Unique identifier for the widget (alphanumeric with underscores only)
- `displayName`: Name shown in the widget gallery
- `description`: Description shown in the widget gallery
- `supportedFamilies`: Array of supported widget sizes (defaults to systemSmall, systemMedium, systemLarge)
## Fallback logic for variants
When a widget size doesn't have specific content defined, Voltra automatically falls back to other available variants in this order:
1. `systemMedium` (preferred fallback for home screen widgets)
2. `systemSmall`
3. `systemLarge`
4. `systemExtraLarge`
5. `accessoryRectangular`
6. `accessoryCircular`
7. `accessoryInline`
This ensures your widget always displays content even when a specific size variant isn't provided. For example, if you only define `systemSmall` and `systemLarge`, a `systemMedium` widget will use the `systemSmall` content.
```typescript
// Only defining systemSmall and systemLarge
await updateWidget('minimal', {
systemSmall: 72°F,
systemLarge: Weather: 72°F - Sunny,
})
// systemMedium will automatically use systemSmall content as fallback
```
## Additional widget APIs
### getActiveWidgets
Detect which widgets are currently installed on the user's home screen:
```typescript
import { getActiveWidgets } from '@use-voltra/ios-client'
const widgets = await getActiveWidgets()
// [{ name: 'weather', family: 'systemSmall', kind: '...' }]
```
See [Querying Active Widgets](/ios/development/querying-active-widgets.md) for more details.
### reloadWidgets
Force widget timelines to refresh their content after updating shared resources like preloaded images:
```typescript
import { reloadWidgets } from '@use-voltra/ios-client'
// Reload specific widgets
await reloadWidgets(['weather', 'calendar'])
// Reload all widgets
await reloadWidgets()
```
### clearWidget / clearAllWidgets
Remove stored widget data, causing widgets to show their placeholder state:
```typescript
import { clearWidget, clearAllWidgets } from '@use-voltra/ios-client'
// Clear specific widget
await clearWidget('weather')
// Clear all widgets
await clearAllWidgets()
```
---
url: /ios/development/dynamic-widgets.md
---
# Dynamic Widgets
:::warning Experimental Feature
Dynamic widgets are experimental. Please [report any issues](https://github.com/callstackincubator/voltra/issues) you find.
:::
Dynamic Widgets let your widget react to the current device state on iOS. Declare them in `app.json` with a stable `id` and an explicit `entry`, then default-export the widget from that file.
That means your widget can react to:
- `env.widgetFamily`
- `env.colorScheme`
- `env.locale`
- `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.
## How to use it
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.
```tsx
import { Voltra, type WidgetEnvironment } from '@use-voltra/ios'
export default function WeatherWidget(_props: object, env: WidgetEnvironment = {} as WidgetEnvironment) {
const renderedAt = env.date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})
return (
Weather Widget
Family: {env.widgetFamily}
Scheme: {env.colorScheme ?? 'light'}
Rendered: {renderedAt}
)
}
```
Example plugin config:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"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"
}
]
}
]
]
}
}
```
If you want user-editable values, add `appIntent` too. See [Configurable Widgets](/ios/development/configurable-widgets.md).
## Notes
- 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.
---
url: /ios/development/events.md
---
# Events
Voltra emits several types of events that allow your app to respond to changes in Live Activities and user interactions.
## Overview
Voltra provides four main event types:
- **Activity state changes**: Notifications when a Live Activity's state changes (e.g., dismissed, ended)
- **Push tokens**: Tokens needed for server-side updates via push notifications
- **Push-to-start tokens**: Tokens for starting Live Activities remotely (iOS 17.2+)
- **User interactions**: Events triggered when users interact with buttons or toggles in your Live Activity
## Activity state changes
Voltra emits events whenever a Live Activity's state changes. This allows you to track the lifecycle of your Live Activities and respond accordingly.
### Listening for state changes
Use `addVoltraListener` with the `'stateChange'` event type to subscribe to state change events:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('stateChange', (event) => {
console.log('Activity name:', event.activityName)
console.log('New state:', event.activityState)
// Handle different states
if (event.activityState === 'dismissed') {
// User dismissed the Live Activity
} else if (event.activityState === 'ended') {
// Live Activity ended
} else if (event.activityState === 'active') {
// Live Activity is active
}
})
// Don't forget to clean up
subscription.remove()
```
### Activity states
The `activityState` field can have the following values:
- `'active'`: The Live Activity is currently active and visible
- `'dismissed'`: The user manually dismissed the Live Activity
- `'pending'`: The Live Activity is pending activation
- `'stale'`: The Live Activity has become stale
- `'ended'`: The Live Activity has ended
### Event structure
Each state change event contains:
- `activityName`: The name of the Live Activity (as specified when starting it)
- `activityState`: The new state of the Live Activity
## Push tokens
To enable server-side updates for your Live Activities, you need to obtain push tokens. Voltra emits events when these tokens become available.
### Activity push tokens
Activity push tokens are used to update existing Live Activities via push notifications. Listen for these tokens using `addVoltraListener` with the `'activityTokenReceived'` event type:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('activityTokenReceived', (event) => {
console.log('Activity name:', event.activityName)
console.log('Push token:', event.pushToken)
// Send the token to your server
sendTokenToServer({
activityName: event.activityName,
pushToken: event.pushToken,
})
})
subscription.remove()
```
For more information about using push tokens for server-side updates, see the [server-side updates guide](/ios/development/server-side-updates.md).
### Push-to-start tokens
Push-to-start tokens (available on iOS 17.2+) allow you to start Live Activities remotely via push notifications. Listen for these tokens using `addVoltraListener` with the `'activityPushToStartTokenReceived'` event type:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('activityPushToStartTokenReceived', (event) => {
console.log('Push-to-start token:', event.pushToStartToken)
// Send the token to your server
sendTokenToServer({
pushToStartToken: event.pushToStartToken,
})
})
subscription.remove()
```
For more information about using push tokens for starting Live Activity remotely, see the [server-side updates guide](/ios/development/server-side-updates.md).
## User interactions
When users interact with buttons or toggles in your Live Activity, Voltra emits events that allow your app to respond to these interactions. This works even when your app isn't running, thanks to Apple's AppIntents framework.
### Listening for interactions
Subscribe to interaction events using `addVoltraListener` with the `'interaction'` event type:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('interaction', (event) => {
console.log('Component interacted:', event.identifier)
console.log('Payload:', event.payload)
// Handle the interaction based on the identifier
if (event.identifier === 'contact-driver') {
// Open contact screen
} else if (event.identifier === 'notifications-toggle') {
// Handle toggle state change
}
})
subscription.remove()
```
For detailed information about handling interactions, including component identifiers, deep linking, and app lifecycle considerations, see the [interactions guide](/ios/development/interactions.md).
## Best practices
### Set up listeners early
Initialize your event listeners as early as possible in your app lifecycle (e.g., in your root component or app entry point). This ensures they're ready when events occur, especially when your app is launched from a terminated state due to a Live Activity interaction.
### Clean up subscriptions
Always clean up your event subscriptions to prevent memory leaks. If you're using React hooks, return a cleanup function from `useEffect`:
```typescript
import { useEffect } from 'react'
import { addVoltraListener } from '@use-voltra/ios-client'
function MyComponent() {
useEffect(() => {
const subscription = addVoltraListener('stateChange', (event) => {
// Handle event
})
return () => {
subscription.remove()
}
}, [])
return null
}
```
### Handle app launch scenarios
When your app is launched from a terminated state (e.g., due to a Live Activity interaction), make sure your event listeners are set up before processing any queued events. Voltra handles event queuing automatically when using App Groups.
---
url: /ios/development/flexbox-layout.md
---
# Flexbox Layout
:::warning Experimental Feature
Flexbox layout is a **new layout system** in Voltra. While it covers common use cases, you may encounter bugs or edge cases. Please [report any issues](https://github.com/callstackincubator/voltra/issues) you find.
:::
Voltra supports React Native-style flexbox layout through the `Voltra.View` component.
## Enabling Flexbox
Use `Voltra.View` to opt in to flexbox layout:
```tsx
LeftRight
```
## Supported Properties
Voltra supports the following flexbox style properties. For detailed explanations and examples of each property, see the [React Native Flexbox documentation](https://reactnative.dev/docs/flexbox).
### Container Properties
| Property | Supported Values |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| `flexDirection` | `'column'` (default), `'row'` |
| `justifyContent` | `'flex-start'` (default), `'center'`, `'flex-end'`, `'space-between'`, `'space-around'`, `'space-evenly'` |
| `alignItems` | `'stretch'` (default), `'flex-start'`, `'center'`, `'flex-end'` |
| `gap` | Number (points) — applies along main axis only |
### Child Properties
| Property | Description |
| ------------------------ | ---------------------------------------------- |
| `flex` | Shorthand for `flexGrow` and `flexShrink` |
| `flexGrow` | How much the item should grow (default: `0`) |
| `flexShrink` | How much the item should shrink (default: `0`) |
| `flexBasis` | Initial size before flex grow/shrink |
| `alignSelf` | Override parent's `alignItems` for this child |
| `width`, `height` | Fixed dimensions |
| `minWidth`, `maxWidth` | Width constraints |
| `minHeight`, `maxHeight` | Height constraints |
| `margin` | Outer spacing |
| `padding` | Inner spacing |
### Unsupported Properties
The following React Native flexbox properties are **not supported** in Voltra:
- `flexWrap` — items are always laid out in a single line
- `rowGap`, `columnGap` — only the unified `gap` property is supported, applied along the main axis
- Percentage-based `width` or `height` (e.g. `width: '50%'`) — use `flexGrow` instead
## Differences from React Native
If you're coming from React Native, keep these differences in mind:
### Flexbox is opt-in
In React Native, every `View` uses flexbox by default. In Voltra, only `Voltra.View` uses flexbox. Other containers (`VStack`, `HStack`) use native SwiftUI layout.
### Gap is single-axis only
React Native supports `gap`, `rowGap`, and `columnGap`. Voltra only supports `gap`, which applies spacing between children along the main axis (the direction of `flexDirection`).
### No flex wrap
React Native supports `flexWrap: 'wrap'` to flow items onto multiple lines. Voltra does not — all items stay on a single line and will overflow or shrink.
### No percentage dimensions
React Native allows `width: '50%'` and similar percentage values. In Voltra, use `flexGrow` for proportional sizing instead.
## Next Steps
- [React Native Flexbox documentation](https://reactnative.dev/docs/flexbox) — comprehensive guide to flexbox properties and concepts
- [View component documentation](/ios/components/layout.md#view) — API reference for `Voltra.View`
- [Styling in Voltra](/ios/development/styling.md) — more about style properties
---
url: /ios/development/image-preloading.md
---
# Image Preloading
This page provides detailed API documentation for Voltra's image preloading system. For an overview of all image handling approaches in Live Activities, see the [Images](/ios/development/images.md) documentation.
Live Activities have strict size limits (4KB per update), which makes displaying remote images challenging. The image preloading API downloads images to shared App Group storage, making them available to both your app and Live Activities.
## Overview
The image preloading system works by:
1. Downloading images from URLs to App Group shared storage
2. Validating that images are under the 4KB ActivityKit limit
3. Making images available to Live Activities via the `assetName` property
4. Providing APIs to reload existing Live Activities when new images are available
## API Reference
### `preloadImages(images: PreloadImageOptions[]): Promise`
Downloads bitmap images to App Group storage or rasterizes SVGs to PNG before storing them for use in Live Activities.
Each item must be either a URL preload (`url`) or an SVG preload (`svg`). The TypeScript types express this as a union of two variants:
```typescript
type PreloadImageUrlOptions = {
key: string // The assetName to use when referencing this image
url: string // URL to download the image from
method?: 'GET' | 'POST' | 'PUT' // HTTP method (default: 'GET')
headers?: Record // Optional HTTP headers
width?: number
height?: number
}
type PreloadImageSvgOptions = {
key: string // The assetName to use when referencing this image
svg: string // Inline SVG markup to rasterize and cache as PNG
width?: number // Typically required for SVG rasterization (see native errors if omitted)
height?: number
}
type PreloadImageOptions = PreloadImageUrlOptions | PreloadImageSvgOptions
type PreloadImageFailure = {
key: string
error: string
}
type PreloadImagesResult = {
succeeded: string[] // Keys of successfully downloaded images
failed: PreloadImageFailure[] // Failed downloads with error messages
}
```
**Example:**
```typescript
import { preloadImages } from '@use-voltra/ios-client'
const result = await preloadImages([
{
url: 'https://example.com/album-art.jpg',
key: 'current-song-artwork',
headers: { Authorization: 'Bearer token' },
},
{
key: 'status-icon-active',
svg: '',
width: 24,
height: 24,
},
])
console.log('Succeeded:', result.succeeded)
console.log('Failed:', result.failed)
```
### `reloadLiveActivities(activityNames?: string[]): Promise`
Reloads Live Activities to pick up newly preloaded images. If no `activityNames` are provided, all active Live Activities will be reloaded.
```typescript
import { reloadLiveActivities } from '@use-voltra/ios-client'
// Reload all Live Activities
await reloadLiveActivities()
// Reload specific activities
await reloadLiveActivities(['music-player', 'order-tracker'])
```
### `clearPreloadedImages(keys?: string[]): Promise`
Removes preloaded images from App Group storage. If no `keys` are provided, all preloaded images will be cleared.
```typescript
import { clearPreloadedImages } from '@use-voltra/ios-client'
// Clear specific images
await clearPreloadedImages(['album-art', 'profile-pic'])
// Clear all preloaded images
await clearPreloadedImages()
```
## Usage in Live Activities
Once images are preloaded, reference them using the `assetName` property:
```typescript
import { Voltra } from '@use-voltra/ios'
function MusicPlayerLiveActivity({ song }) {
return {
lockScreen: (
{song.title}
{song.artist}
)
}
}
```
## Size Validation
ActivityKit enforces a 4KB limit per Live Activity update. Images are validated both before and after download:
- **Content-Length header**: If available, images larger than 4KB are rejected before download
- **Actual size**: After download, the actual data size is validated
- **SVG rasterization**: SVG inputs are converted to PNG before storage and the generated PNG is size-checked
- **Image validation**: Ensures the downloaded data is a valid image format
Images that exceed 4KB will result in a preload failure with a descriptive error message.
## Error Handling
The `preloadImages` function provides detailed error information:
```typescript
const result = await preloadImages([
{ url: 'https://example.com/large-image.jpg', key: 'big-image' },
{ url: 'https://invalid-url.com/image.jpg', key: 'broken-url' },
])
// Handle results
if (result.failed.length > 0) {
result.failed.forEach(({ key, error }) => {
console.error(`Failed to preload ${key}: ${error}`)
})
}
// Only proceed if all images succeeded
if (result.succeeded.length === 2) {
await reloadLiveActivities()
}
```
## Common Error Messages
- `"Image 'key-name' is too large: 5120 bytes (max 4096 bytes for Live Activities)"` - Image exceeds 4KB limit
- `"Invalid image data for 'key-name'"` - Downloaded data is not a valid image
- `"Invalid SVG data for 'key-name'"` - SVG markup is malformed or contains unsupported external/script references
- `"SVG 'key-name' requires positive width and height"` - SVG rasterization needs explicit dimensions
- `"HTTP error: 404"` - Server returned an error status code
- `"App Group not configured"` - Missing App Group configuration in the config plugin
## Configuration
Image preloading requires App Group configuration in your Expo config plugin:
```json
{
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.your.app"
}
]
]
}
```
---
url: /ios/development/images.md
---
# Images
Live Activities have strict size limits (4KB per update), making image handling a critical optimization area. Voltra provides three different approaches for including images in your Live Activities, each with different trade-offs and use cases:
- **Base64 encoding**: Best for small, static images (\< 1KB)
- **Build-time asset copying**: Best for medium-sized images that are known at build time
- **Runtime preloading**: Best for dynamic images from remote URLs or inline SVG data
## Base64 encoding
For extremely small images, you can embed them directly as base64-encoded strings in your JSX.
```tsx
```
## Build-time asset copying
Place images in the `/assets/voltra/` directory and they'll be automatically copied to the iOS extension bundle during build.
```
project-root/
├── assets/
│ └── voltra/
│ ├── logo.png
│ ├── icon-star.png
│ └── background-pattern.png
```
Here's how build-time asset copying works:
1. Images in `/assets/voltra/` are automatically detected during build
2. Each image is validated to be under 4KB (ActivityKit limit)
3. Images are copied to the Live Activity extension's `Assets.xcassets`
4. Xcode generates proper `.imageset` directories and metadata
You can then reference these images using their assetName:
```tsx
```
## Runtime preloading
For dynamic images from remote URLs or inline SVG markup, use Voltra's image preloading API to cache images in App Group shared storage. SVG inputs are rasterized to PNG before storage.
The image preloading system works by:
1. Downloading images from URLs to App Group shared storage
2. Validating that images are under the 4KB ActivityKit limit
3. Making images available to Live Activities via the `assetName` property
4. Providing APIs to reload existing Live Activities when new images are available
Once images are preloaded, reference them using the `assetName` property:
```typescript
import { Voltra } from '@use-voltra/ios'
function MusicPlayerLiveActivity({ song }) {
return {
lockScreen: (
{song.title}
{song.artist}
)
}
}
```
For detailed API documentation, see [Image Preloading](/ios/development/image-preloading.md).
## Comparison table
| Approach | Image Size | When Known | Dynamic | Setup Required | Payload Impact |
| ---------- | ---------- | ---------- | ------- | ---------------- | ------------------- |
| Base64 | \< 1KB | Build time | No | None | High (encoded size) |
| Build-time | 1KB - 4KB | Build time | No | File placement | Low (filename only) |
| Preloading | 1KB - 4KB | Runtime | Yes | App Group config | Low (filename only) |
---
url: /ios/development/interactions.md
---
# Interactions
Voltra components leverage Apple's ActivityKit to provide interactive Live Activities.
## Interaction capabilities
ActivityKit provides a limited set of interactions with Live Activities. The only interactable elements are:
- **Live Activity view**: Tapping anywhere on the Live Activity itself can launch your app via a deep link, allowing users to access more detailed information or perform specific actions.
- **Links**: Navigation elements that open URLs when tapped. Links use SwiftUI's native Link component and work across all Live Activity contexts (iOS 14.0+).
- **Buttons**: Interactive buttons that trigger in-app events. Buttons are implemented using AppIntents and work on both the Lock Screen and Dynamic Island (iOS 17.0+).
- **Toggles**: Interactive toggle switches that allow users to change boolean states. Like buttons, toggles use AppIntents and are supported across all Live Activity contexts (iOS 17.0+).
These interactions are powered by Apple's AppIntents framework and SwiftUI's Link component, which enable Live Activities to communicate with your app even when it's not running.
## Handling interactions
When a user interacts with a button or toggle in your Live Activity, Voltra automatically emits an event containing the identifier of the component that was interacted with. This allows your app to respond appropriately to specific user actions.
### Listening for events
To handle interactions, subscribe to Voltra UI events using the `addVoltraListener` function with the `'interaction'` event type:
```typescript
import { addVoltraListener } from '@use-voltra/ios-client'
const subscription = addVoltraListener('interaction', (event) => {
console.log('Component interacted:', event.identifier)
console.log('Component type:', event.componentType)
// Handle the interaction based on the identifier
if (event.identifier === 'contact-driver') {
// Open contact screen
} else if (event.identifier === 'toggle') {
// Handle toggle state change
}
})
// Don't forget to clean up
subscription.remove()
```
### Event structure
Each interaction event contains:
- `identifier`: The unique identifier of the component that was interacted with (as specified in your JSX via the `id` prop)
- `componentType`: The type of component that triggered the event (e.g., "button", "toggle")
### Component identifiers
To receive interaction events, make sure your interactive components have unique identifiers:
```typescript
Contact driver
```
If you don't provide an explicit `id` prop, Voltra will automatically generate a deterministic identifier based on the component's position in the tree. However, for reliable event handling, it's recommended to use explicit identifiers.
## Deep linking
### Activity-level deep links
When users tap on the Live Activity itself (not on a button or link), your app can be launched via a deep link. Configure the deep link URL when starting a Live Activity:
```typescript
import { useLiveActivity } from '@use-voltra/ios-client'
const { start } = useLiveActivity(
{
lockScreen: ,
},
{
activityName: 'activity-detail',
deepLinkUrl: '/voltraui/activity-detail',
}
)
```
The deep link URL can be:
- A full URL with a scheme (e.g., `myapp://activity-detail`)
- A path that will be prefixed with your app's URL scheme (e.g., `/activity-detail`)
When the Live Activity is tapped, your app will be launched (or brought to the foreground) and the deep link will be handled by your routing system.
### Component-level deep links
Individual components can also open URLs when tapped, providing granular navigation within your Live Activity.
#### Link Component
The `Link` component provides semantic navigation to URLs. It uses SwiftUI's native Link and works on iOS 14.0+:
```tsx
Order #123
```
Links support URL normalization:
- Absolute URLs: `"myapp://path"`, `"https://example.com"`
- Relative paths: `"/settings"` → `"myapp://settings"`
- Simple paths: `"help"` → `"myapp://help"`
For styled navigation buttons, wrap a Link with custom children to achieve button-like appearance while maintaining URL navigation functionality.
## Limitations
While Live Activities provide powerful interaction capabilities, there are some limitations to be aware of:
- **Limited interactable elements**: Only buttons and toggles are supported as interactive components. Other UI elements like text fields, sliders, or custom controls are not available in Live Activities.
- **iOS version requirements**: Interactive buttons and toggles require iOS 17.0+. On iOS 16.x, these components will render but will not be interactive.
- **No real-time updates from interactions**: When a user interacts with a button or toggle, the Live Activity UI doesn't update automatically. You'll need to update the Live Activity content manually using the `update` function if you want to reflect state changes.
- **Event delivery timing**: Events are delivered asynchronously. There may be a slight delay between the user's interaction and your event handler being called, especially if the app needs to be launched.
## Example
Here's a complete example showing how to handle interactions:
```typescript
import { useEffect } from 'react'
import { Voltra } from '@use-voltra/ios'
import { useLiveActivity } from '@use-voltra/ios-client'
import { addVoltraListener } from '@use-voltra/ios-client'
function MyLiveActivity() {
const { start, update } = useLiveActivity(
{
lockScreen: (
Music PlayerPlayPause
),
},
{
activityName: 'music-player',
deepLinkUrl: '/music-player',
}
)
useEffect(() => {
const subscription = addVoltraListener('interaction', (event) => {
switch (event.identifier) {
case 'play-button':
// Start playback
console.log('Play button tapped')
break
case 'pause-button':
// Pause playback
console.log('Pause button tapped')
break
case 'shuffle-toggle':
// Toggle shuffle mode
console.log('Shuffle toggled')
break
}
})
return () => subscription.remove()
}, [])
return null
}
```
---
url: /ios/development/managing-live-activities-locally.md
---
# Managing Live Activities locally
Live Activities are dynamic interfaces that display real-time information on iOS devices. Voltra provides a comprehensive set of APIs for managing the complete lifecycle of Live Activities directly from your React Native app, without requiring server-side infrastructure.
## Overview
Managing Live Activities locally involves four main phases:
1. **Starting** a Live Activity with initial content and configuration
2. **Updating** the content and configuration as data changes
3. **Monitoring** state changes and user interactions
4. **Stopping** the Live Activity when it's no longer needed
Voltra offers both imperative APIs for direct control and React hooks for seamless integration with your components.
## Imperative APIs
The imperative APIs provide direct, programmatic control over Live Activities. These are the core functions you'll use to manage Live Activity lifecycles.
### Starting Live Activities
Use `startLiveActivity()` to create and display a new Live Activity.
```typescript
import { Voltra } from '@use-voltra/ios'
import { startLiveActivity } from '@use-voltra/ios-client'
const variants = {
lockScreen: (
Order Confirmed
Your order is being prepared
),
// Define compact, minimal, and expanded variants for Dynamic Island
compact: Order confirmed,
minimal: ,
expanded: (
Order Confirmed
Your order is being prepared
),
}
const activityId = await startLiveActivity(variants, {
activityName: 'order-123', // Optional: for re-binding on app restart
deepLinkUrl: 'myapp://order/123', // Optional: URL to open when tapped
channelId: 'CTrNsYq/Ee8AALLzHQaVlA==', // Optional: broadcast channel (iOS 18+)
dismissalPolicy: { after: 30 },
staleDate: Date.now() + 60 * 60 * 1000, // 1 hour
relevanceScore: 0.8,
})
```
**Parameters:**
- `variants`: A `LiveActivityVariants` object defining the UI for different display contexts
- `options`: Configuration options (see Configuration Options section below)
**Returns:** A promise that resolves to the Live Activity ID (string)
### Updating Live Activities
Use `updateLiveActivity()` to modify the content and configuration of an active Live Activity.
```typescript
import { Voltra } from '@use-voltra/ios'
import { updateLiveActivity } from '@use-voltra/ios-client'
const updatedVariants = {
lockScreen: (
Order Ready
Your order is ready for pickup
I'm Here
),
compact: Ready for pickup,
minimal: ,
}
await updateLiveActivity(activityId, updatedVariants, {
dismissalPolicy: { after: 300 }, // 5 minutes
relevanceScore: 1.0, // High priority now
})
```
**Parameters:**
- `activityId`: The ID returned from `startLiveActivity()`
- `variants`: Updated UI variants
- `options`: Updated configuration options
### Stopping Live Activities
Use `stopLiveActivity()` to end a Live Activity.
```typescript
import { stopLiveActivity } from '@use-voltra/ios-client'
await stopLiveActivity(activityId, {
dismissalPolicy: { after: 10 }, // Keep visible for 10 seconds after ending
})
```
**Parameters:**
- `activityId`: The ID of the Live Activity to stop
- `options`: Final configuration options
### Checking Live Activity status
Use `isLiveActivityActive()` to check if a specific Live Activity is currently active.
```typescript
import { isLiveActivityActive } from '@use-voltra/ios-client'
if (isLiveActivityActive('order-123')) {
console.log('Live Activity is active')
} else {
console.log('Live Activity is not active')
}
```
**Parameters:**
- `activityName`: The name of the Live Activity to check (same as `activityName` used when starting the activity)
**Returns:** Boolean indicating whether the Live Activity is currently active
### Utility functions
#### Platform detection
```typescript
import { isGlassSupported, useIsHeadless } from '@use-voltra/ios-client'
function App() {
const isHeadless = useIsHeadless()
// Check if the device supports Liquid Glass (iOS 26+)
if (isGlassSupported()) {
// Use Liquid Glass features
}
// Check if app was launched in background
if (isHeadless) {
// App was launched in background (e.g., from Live Activity interaction)
// Perform background tasks without UI
}
}
```
#### Ending all Live Activities
Use `endAllLiveActivities()` to immediately end all active Live Activities in your app.
```typescript
import { endAllLiveActivities } from '@use-voltra/ios-client'
// End all Live Activities (useful for cleanup or logout scenarios)
await endAllLiveActivities()
```
**Note:** This function ends all Live Activities immediately without applying any dismissal policies. Use this for bulk cleanup scenarios rather than individual activity management.
## Development tools
For development-friendly APIs that provide automatic updates and easier testing, see the [Development documentation](/ios/development/developing-live-activities.md).
## Event handling
Live Activities emit events that you can listen to for state changes and user interactions. See the [Events API documentation](/ios/development/events.md) for detailed information on subscribing to and handling Live Activity events.
## Configuration options
Voltra provides several configuration options to control Live Activity behavior, lifecycle, and appearance. These options can be used with `startLiveActivity()`, `updateLiveActivity()`, and `stopLiveActivity()`.
### Dismissal policy
Controls how Live Activities behave after they end.
**Options:**
- **`'immediate'`** (default): Live Activity is dismissed immediately when it ends
- **`{ after: number }`**: Live Activity remains visible for the specified number of seconds after ending, then automatically dismisses
**Examples:**
```typescript
// Immediate dismissal (default)
await startLiveActivity(variants, {
dismissalPolicy: 'immediate',
})
// Keep visible for 30 seconds after ending
await startLiveActivity(variants, {
dismissalPolicy: { after: 30 },
})
// Update dismissal timing for active Live Activities
await updateLiveActivity(activityId, variants, {
dismissalPolicy: { after: 300 }, // 5 minutes
})
// Set dismissal timing when stopping
await stopLiveActivity(activityId, {
dismissalPolicy: { after: 10 },
})
```
The dismissal policy applies to both programmatic ending and natural ending (when timers expire).
### Stale date
Specifies when a Live Activity should be considered stale and automatically dismissed by iOS.
```typescript
// Dismiss after 1 hour
await startLiveActivity(variants, {
staleDate: Date.now() + 60 * 60 * 1000,
})
// Dismiss after 2 hours
await startLiveActivity(variants, {
staleDate: Date.now() + 2 * 60 * 60 * 1000,
})
```
**Note:** If you provide a `staleDate` in the past, it will be ignored.
### Relevance score
Helps iOS prioritize which Live Activities to display when space is limited.
**Range:** 0.0 to 1.0 (default: 0.0)
```typescript
// High priority (e.g., active delivery)
await startLiveActivity(variants, {
relevanceScore: 0.8,
})
// Low priority (e.g., background task)
await startLiveActivity(variants, {
relevanceScore: 0.2,
})
```
Live Activities can receive updates even when your app is in the background or terminated, but they cannot execute JavaScript code. For real-time updates from backgrounded apps, use server-side push notifications.
### Channel ID (broadcast push, iOS 18+)
Subscribes the Live Activity to a broadcast channel for server-side updates. When provided, the activity receives updates via broadcast push notifications instead of individual device tokens—one server notification updates all activities on that channel.
```typescript
// For shared content (e.g., live sports, flight status)
await startLiveActivity(variants, {
activityName: 'match-123',
channelId: 'CTrNsYq/Ee8AALLzHQaVlA==', // From APNs channel management
})
```
Requires `enablePushNotifications: true` in the Voltra plugin and the Broadcast Capability enabled in your Apple Developer account. See [Server-side updates](/ios/development/server-side-updates.md#broadcast-push-notifications-ios-18) for details.
## Best practices
### Activity lifecycle management
- Always provide meaningful `activityId` values for re-binding on app restart
- Clean up Live Activities when they're no longer relevant
- Use appropriate dismissal policies based on your use case
### Performance considerations
- Keep UI variants lightweight and avoid complex component trees
- Use appropriate relevance scores to ensure important activities are visible
- Set reasonable stale dates to prevent accumulation of outdated activities
### User experience
- Provide deep link URLs for navigation when Live Activities are tapped
- Use meaningful compact and minimal variants for Dynamic Island
- Consider dismissal timing carefully - users may want to see final states
---
url: /ios/development/performance.md
---
# Performance
Voltra provides automatic optimizations to help you create efficient Live Activities, but following these best practices will ensure optimal performance and stay within ActivityKit's payload size limits.
## Element deduplication
Reuse JSX elements by creating them once, storing them in variables, and reusing them across your JSX tree. Voltra automatically detects duplicate element references and stores them only once in the payload, using lightweight references (`{ $r: index }`) for subsequent occurrences.
```tsx
import { Voltra } from '@use-voltra/ios'
// ✅ Good: Create element once and reuse
const sharedButton = Click me
const sharedIcon =
const variants = {
lockScreen: (
Order Status
{sharedButton}
{sharedIcon}
),
island: {
minimal: (
{sharedIcon}
{sharedButton}
),
},
}
```
**Payload result:**
```json
{
"v": 2,
"e": [
{ "t": 1, "p": { "onPress": "action" }, "c": "Click me" },
{ "t": 2, "p": { "name": "star.fill" } }
],
"ls": {
"t": 11,
"c": [{ "t": 0, "c": "Order Status" }, { "$r": 0 }, { "$r": 1 }]
},
"isl_min": {
"t": 12,
"c": [{ "$r": 1 }, { "$r": 0 }]
}
}
```
**Benefits:**
- Eliminates redundant element serialization
- Can significantly reduce payload size for complex UIs
- Works across different Live Activity variants
- Automatic - no additional code required
```tsx
// ❌ Avoid: Creating separate element instances
const variants = {
lockScreen: (
Order StatusClick me // Duplicate instance
// Duplicate instance
),
island: {
minimal: (
// Another duplicate instance
Click me // Another duplicate instance
),
},
}
```
## Image optimization
Images can significantly impact payload size. For comprehensive guidance on handling images in Live Activities, see the [Images](/ios/development/images.md) documentation, which covers:
- Base64 encoding for small static images
- Build-time asset copying for medium-sized images
- Runtime preloading for dynamic images
Choose the appropriate approach based on your image size, when it's known, and whether it changes dynamically.
## Reuse style objects
Reuse style objects and avoid inline styles when possible:
```tsx
// ✅ Good: Shared style objects
const buttonStyle = { padding: 8, borderRadius: 6 }
const textStyle = { fontSize: 14, color: '#666' }
TitleAction
// ❌ Avoid: Inline styles everywhere
TitleAction
```
---
url: /ios/development/querying-active-widgets.md
---
# Querying Active Widgets
Voltra allows you to detect which widgets the user has currently placed on their Home Screen. This is useful for:
- Determining if you need to update a specific widget
- Showing a list of active widgets in your app settings
- Optimizing background updates by only targeting installed widgets
## getActiveWidgets API
The `getActiveWidgets` function returns a promise that resolves to an array of all active widget configurations for your app.
:::warning
There may be a slight delay in the data returned by this API. iOS caches widget configurations, and it might take a few moments for the system to reflect recent additions or removals of widgets on the Home Screen.
:::
```typescript
import { getActiveWidgets } from '@use-voltra/ios-client'
async function checkWidgets() {
const activeWidgets = await getActiveWidgets()
console.log(`User has ${activeWidgets.length} widgets installed`)
activeWidgets.forEach(widget => {
console.log(`- Widget Name: ${widget.name}`)
console.log(` Family: ${widget.family}`)
console.log(` Kind: ${widget.kind}`)
})
}
```
### WidgetInfo Object
Each object in the returned array contains:
| Property | Type | Description |
| :------- | :------------- | :------------------------------------------------------------------------------------- |
| `name` | `string` | The unique ID of the widget as defined in your Expo config plugin (e.g., `"weather"`). |
| `kind` | `string` | The internal identifier string used by iOS (e.g., `"Voltra_Widget_weather"`). |
| `family` | `WidgetFamily` | The size of the widget instance (e.g., `"systemSmall"`, `"systemMedium"`, etc.). |
---
url: /ios/development/server-driven-widgets.md
---
# Server-driven widgets
Server-driven widgets allow your Home Screen widgets to periodically fetch fresh content from a remote server—without the user opening the app. This is ideal for widgets that display dynamic data like weather, news, stock prices, or live scores.
Before you start, make sure the widget is registered in the Voltra plugin config and plan to rebuild the native app after adding or changing server-driven widget settings.
## How it works
1. You configure a `serverUpdate` URL in your widget's plugin config
2. iOS WidgetKit calls your server at the configured interval
3. Your server renders Voltra JSX components into a JSON payload
4. The widget extension parses the payload and updates the widget
The entire lifecycle is managed by the OS timeline system. Your app doesn't need to be running.
## Plugin configuration
Add the `serverUpdate` option to your widget in `app.json` or `app.config.js`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"widgets": [
{
"id": "dynamic_weather",
"displayName": "Dynamic Weather",
"description": "Weather with live server updates",
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 30
}
}
]
}
]
]
}
}
```
**`serverUpdate` options:**
- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, `family`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=ios&family=systemSmall&theme=dark`).
- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. iOS WidgetKit may throttle requests; the minimum effective interval is \~15 minutes.
- `refresh`: Whether to show a native refresh button in the top-right corner of the widget. When tapped, triggers an immediate server fetch. Defaults to `false`. Requires iOS 17+.
After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native files and widget extension pick up the new server update settings.
## Building the server
Voltra provides widget server handlers for the common runtime styles. Use `createWidgetUpdateHandler()` for Fetch-compatible runtimes, `createWidgetUpdateNodeHandler()` for `node:http`, and `createWidgetUpdateExpressHandler()` for Express-style handlers. All three share the same request parsing, platform validation, token validation, and response serialization.
```tsx
import { createServer } from 'node:http'
import React from 'react'
import { createIOSWidgetUpdateNodeHandler, Voltra } from '@use-voltra/ios-server'
const handler = createIOSWidgetUpdateNodeHandler({
render: async (req) => {
// req.widgetId — the widget requesting an update
// req.platform — always "ios" for iOS widget requests
// req.family — the widget size ("systemSmall", "systemMedium", etc.)
// req.theme — the system color scheme ("light" or "dark")
// req.token — the auth token (if credentials were set)
const weather = await fetchWeatherData()
return {
systemSmall: (
{weather.temp}°{weather.condition}
),
systemMedium: (
{weather.temp}°{weather.condition}
H: {weather.high}° L: {weather.low}°
),
}
},
validateToken: async (token) => {
// Return true if the token is valid, false to reject with 401
return token === 'valid-token'
},
})
createServer(handler).listen(3333, () => {
console.log('Widget server running on http://localhost:3333')
})
```
The handler responds to GET requests with these query parameters:
| Parameter | Description |
| ---------- | ------------------------------------------------------------------ |
| `widgetId` | The widget identifier (required) |
| `platform` | The requesting platform. Must be `ios` for iOS widgets (required). |
| `family` | The widget family/size (iOS only) |
| `theme` | The system color scheme (`light` or `dark`) |
The `Authorization: Bearer ` header is automatically extracted and passed to `validateToken` and `render`. The `User-Agent` header is set to `VoltraWidget/1.0 (iOS/)`.
For Fetch-native runtimes, use `createWidgetUpdateHandler()` instead of the Node adapter:
```tsx
import { createIOSWidgetUpdateHandler, Voltra } from '@use-voltra/ios-server'
export const GET = createIOSWidgetUpdateHandler({
render: async (req) => ({
systemSmall: {req.widgetId},
}),
})
```
## Authentication
Widgets run in a separate extension process and can't access your app's network layer or auth state. Voltra solves this by storing credentials in the **Shared Keychain**, which is accessible by both the main app and the widget extension.
### Setting credentials
Call `setWidgetServerCredentials` after the user logs in:
```typescript
import { setWidgetServerCredentials } from '@use-voltra/ios-client'
await setWidgetServerCredentials({
token: userAccessToken,
headers: {
'X-App-Version': '1.0.0',
},
})
```
The `token` is required and is sent as `Authorization: Bearer ` on every server request. Any additional `headers` are also included. If your widget endpoint does not require authentication, skip `setWidgetServerCredentials()` entirely.
### Clearing credentials
Call `clearWidgetServerCredentials` when the user logs out:
```typescript
import { clearWidgetServerCredentials } from '@use-voltra/ios-client'
await clearWidgetServerCredentials()
```
All widget timelines are automatically reloaded after credentials are cleared, so widgets revert to their default/unauthenticated state immediately.
### Keychain group
For credentials to be shared between the main app and the widget extension, both must belong to the same Keychain Access Group. This is configured via the `keychainGroup` plugin option:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"keychainGroup": "$(AppIdentifierPrefix)com.example.shared",
"widgets": [...]
}
]
]
}
}
```
If you don't specify `keychainGroup` but any widget has `serverUpdate` configured, Voltra automatically derives a default: `$(AppIdentifierPrefix)`.
## Refresh button
Server-driven widgets can display a native refresh button that lets users trigger an immediate update on demand. Enable it in your widget config:
```json
{
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 30,
"refresh": true
}
}
```
When enabled, a small circular button (↻) appears in the top-right corner of the widget. Tapping it triggers `reloadTimelines(ofKind:)` via an `AppIntent`, which causes WidgetKit to immediately fetch fresh content from your server.
:::note
The refresh button requires iOS 17+ (`AppIntent` API). On older iOS versions, the button is not shown.
:::
### Fetch coalescing
When WidgetKit reloads timelines, it may call `getTimeline` multiple times for each supported family (e.g. `systemSmall`, `systemMedium`). To avoid redundant network requests, Voltra coalesces fetches within a 3-second window per widget. Only the first call triggers a server fetch; subsequent calls within the window use cached data and `selectContentForFamily` picks the correct family-specific content.
## Triggering manual refreshes
You can force-refresh server-driven widgets outside of the regular interval:
```typescript
import { reloadWidgets } from '@use-voltra/ios-client'
// Reload specific widgets
await reloadWidgets(['dynamic_weather'])
// Reload all widgets
await reloadWidgets()
```
This triggers an immediate timeline refresh, which causes WidgetKit to call your server for new content.
## Initial state
Server-driven widgets still need content to display before the first server fetch completes (e.g. when the widget is first added to the Home Screen). Use `initialStatePath` to provide a pre-rendered default:
```json
{
"id": "dynamic_weather",
"displayName": "Dynamic Weather",
"description": "Weather with live server updates",
"supportedFamilies": ["systemSmall", "systemMedium"],
"initialStatePath": "./widgets/ios/weather-initial.tsx",
"serverUpdate": {
"url": "https://api.example.com/widgets/render",
"intervalMinutes": 30
}
}
```
See [Widget pre-rendering](/ios/development/widget-pre-rendering.md) for details on creating initial state files.
:::tip
Provide a meaningful initial state (e.g. "Loading..." or placeholder content) rather than leaving it empty. The user sees this until the first server fetch succeeds.
:::
## Architecture overview
```
┌─────────────────┐ setWidgetServerCredentials() ┌──────────────────┐
│ React Native │ ──────────────────────────────────► │ Shared Keychain │
│ (main app) │ └──────────────────┘
└─────────────────┘ │
│ reads token
▼
┌─────────────────┐ GET ?widgetId=X&platform=ios&family=Y&theme=Z ┌──────────────────┐
│ WidgetKit │ ──────────────────────────────────► │ Your Server │
│ (extension) │ ◄────────────────────────────────── │ (Voltra SSR) │
└─────────────────┘ JSON payload └──────────────────┘
│
▼
Home Screen Widget
```
WidgetKit manages the scheduling and calls your server at the configured interval. The widget extension reads credentials from the Shared Keychain, makes the HTTP request, and renders the response payload.
## Error handling and retries
When a server fetch fails, the widget extension falls back to the last successfully fetched data (or the initial state if no data has been fetched yet). WidgetKit schedules a retry after 15 minutes.
- **Network error / timeout:** The widget falls back to cached content and retries in 15 minutes.
- **Server errors (non-2xx):** Same fallback behavior — cached content is shown and a retry is scheduled in 15 minutes.
- **Empty response:** Treated as an error; cached content is displayed.
- **Parse errors:** If the server returns a 2xx response but the JSON can't be parsed into a valid widget tree, the cached data from the previous successful fetch is preserved (not overwritten). The widget continues to show the last known good content.
:::note
Unlike Android's WorkManager which retries with exponential backoff, iOS WidgetKit uses its own timeline-based scheduling. After a failed fetch, the timeline provider falls back to local data and schedules a retry in 15 minutes. WidgetKit may also throttle updates based on battery level and widget visibility.
:::
---
url: /ios/development/server-side-updates.md
---
# Server-side updates
Voltra supports server-side updates for Live Activities through Apple Push Notification Service (APNS). This allows you to update Live Activities even when your app is in the background or terminated.
## Configuration
### Expo Config plugin setup
To enable server-side updates via push notifications, you need to configure the Voltra plugin in your `app.json` or `app.config.js`:
**Minimal configuration (push notifications only):**
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"enablePushNotifications": true
}
]
]
}
}
```
**Full configuration (with App Groups for event forwarding and shared images):**
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"enablePushNotifications": true,
"groupIdentifier": "group.your.app.voltraui",
"deploymentTarget": "18.0"
}
]
]
}
}
```
**Note:** The `groupIdentifier` is optional. It's only needed if you want to forward component events (like button taps) from the Live Activity extension to your JavaScript code, or if you need to share images between your app and the extension. Push notifications work perfectly fine without it.
### Xcode configuration
The Voltra config plugin automatically handles most of the Xcode configuration for you:
- Adds the `aps-environment` entitlement (set to `development` for debug builds)
- Configures Info.plist flags to enable push notification integration
- Sets up App Group entitlements (if `groupIdentifier` is provided)
However, you'll still need to complete a few manual steps:
1. **Provisioning Profiles:** Ensure your app has proper provisioning profiles configured in Xcode with push notification capabilities enabled.
2. **APNs Certificates:** Set up APNs authentication certificates or keys in your Apple Developer account. You'll need these to send push notifications from your server.
3. **Capabilities:** Verify that Push Notifications capability is enabled in your app's target settings in Xcode.
For detailed information about setting up push notifications in iOS, refer to [Apple's official push notification documentation](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server).
## Server-side rendering
Voltra provides a server-side API that allows you to render React components into JSON payloads that can be sent via APNS. The same Voltra components you use in your app work on the server.
### Using the server-side API
Import the server-side rendering functions from `@use-voltra/ios-server`:
```tsx
import { Voltra, renderLiveActivityToString } from '@use-voltra/ios-server'
// Render your UI components to a JSON string
const jsonPayload = renderLiveActivityToString({
lockScreen: (
Driver arrivedReady for pickup
),
})
```
The `renderLiveActivityToString` function accepts a `LiveActivityVariants` object and returns a compressed, base64-encoded JSON string for your APNS payload.
## APNS payload format
When sending push notifications to update Live Activities, you need to structure your APNS payload correctly. For detailed information about APNS payload structure, see Apple's [official ActivityKit push notification documentation](https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications).
You can also use Apple’s [CloudKit - Push Notifications tool](https://icloud.developer.apple.com/dashboard/notifications/) to send test push notifications during development.
### Required APNS headers
```
apns-push-type: liveactivity
apns-topic: .push-type.liveactivity
```
Replace `` with your app's bundle identifier (e.g., `com.example.myapp`).
### Payload structure
**For updating an existing Live Activity:**
```json
{
"aps": {
"event": "update",
"content-state": {
"uiJsonData": "{\"lockScreen\":{\"type\":\"VStack\",\"children\":{\"type\":\"Text\",\"children\":\"Hello\",\"props\":{}},\"props\":{}}}"
},
"timestamp": 1764145755
}
}
```
**For starting a Live Activity remotely (iOS 17.2+):**
```json
{
"aps": {
"event": "start",
"content-state": {
"uiJsonData": "{\"lockScreen\":{\"type\":\"VStack\",\"children\":{\"type\":\"Text\",\"children\":\"Hello\",\"props\":{}},\"props\":{}}}"
},
"attributes-type": "VoltraAttributes",
"attributes": {
"name": "some-name",
"deepLinkUrl": "app://some-deep-link-url"
},
"timestamp": 1764145755,
"alert": {
"title": "Driver arrived",
"body": "Ready for pickup"
}
}
}
```
**For ending an existing Live Activity:**
```json
{
"aps": {
"timestamp": 1683012400,
"event": "end",
"content-state": {
"uiJsonData": "{\"lockScreen\":{\"type\":\"VStack\",\"children\":{\"type\":\"Text\",\"children\":\"Hello\",\"props\":{}},\"props\":{}}}"
},
"dismissal-date": 1683016000
}
}
```
**Key fields:**
- `aps.event`: Set to `"update"` to update, `"end"` to end, or `"start"` for push-to-start (iOS 17.2+)
- `aps.content-state.uiJsonData`: The JSON string returned by `renderLiveActivityToString`, embedded as a string value eg. `ixOAeyJ2IjoxLCJscyI6eyJ0IjowLCJjIjoiSGVsbG8sIHdvcmxkISJ9fQM=`. This must be sent for `update` and `end` events.
- `aps.timestamp`: Unix timestamp in seconds (required for Live Activities)
- `aps.attributes-type`: For push-to-start, must be `"VoltraAttributes"`
- `aps.attributes.name`: For push-to-start, a user-defined name for the activity (can be any string you choose)
- `aps.alert`: Required field for push-to-start
For `event: "end"`, include `aps.dismissal-date`.
To remove the Live Activity from the Lock Screen right after it ends, set `dismissal-date` to a timestamp in the past (for example, 1663177260).
You can also set a timestamp up to four hours in the future to control when it’s dismissed.
:::danger
ActivityKit enforces a strict payload size limit of approximately 4 KB. Keep your UI JSON minimal to stay within this limit. Avoid deeply nested component trees and excessive styling to ensure your payloads fit within the constraint.
:::
## Getting push tokens
Voltra provides specialized push tokens that are different from regular device tokens. These tokens are specifically designed for Live Activity push notifications and should be used instead of standard device tokens.
### Tokens for updating Live Activities
To update existing Live Activities, use activity push tokens:
```tsx
import { addVoltraListener } from '@use-voltra/ios-client'
useEffect(() => {
const subscription = addVoltraListener('activityTokenReceived', ({
activityName,
pushToken,
timestamp,
type
}) => {
// Send the token to your server for updating this specific Live Activity
sendTokenToServer({
activityID,
token: pushToken,
type: 'update',
})
})
return () => {
subscription.remove()
}
}, [])
```
### Tokens for starting Live Activities remotely
To start Live Activities remotely (push-to-start), use push-to-start tokens:
```tsx
useEffect(() => {
const subscription = addVoltraListener('activityPushToStartTokenReceived', ({ pushToStartToken, type }) => {
// Send the token to your server for starting new Live Activities
sendTokenToServer({
token: pushToStartToken,
type: 'start',
})
})
return () => {
subscription.remove()
}
}, [])
```
Use only Voltra-provided tokens, which are specialized for Live Activity push notifications and different from regular device tokens. Update tokens are tied to specific Live Activities, while push-to-start tokens are for starting new activities. Update tokens are provided when Live Activities are started and may change during the activity's lifecycle. When sending notifications via APNS, use these push tokens as the target device token to route notifications to the correct Live Activity or device.
## Broadcast push notifications (iOS 18+)
Starting with iOS 18 and iPadOS 18, you can use **broadcast push notifications** to update many Live Activities with a single push notification. Instead of sending individual notifications to each device token, you send one broadcast to a shared channel—all Live Activities subscribed to that channel receive the update. This is ideal for scenarios like live sports scores or flight status where many users follow the same event.
### Prerequisites
1. **Enable Broadcast Capability:** In your [Apple Developer account](https://developer.apple.com/account), go to Certificates, Identifiers & Profiles > Identifiers, select your App ID, and enable **Broadcast Capability** under Push Notifications.
2. **Create a channel:** Your server creates a channel via APNs and receives a channel ID. You can maintain up to 10,000 channels per app. Use [Apple Push Notification Console](https://icloud.developer.apple.com/dashboard/notifications/) or the [channel management API](https://developer.apple.com/documentation/usernotifications/sending-channel-management-requests-to-apns) to create channels.
3. **Plugin configuration:** Keep `enablePushNotifications: true` in your Voltra plugin config—the `aps-environment` entitlement is still required for broadcast push.
### Starting a Live Activity with a channel
Pass the `channelId` option when starting a Live Activity to subscribe it to a broadcast channel:
```typescript
import { Voltra } from '@use-voltra/ios'
import { startLiveActivity } from '@use-voltra/ios-client'
const activityId = await startLiveActivity(variants, {
activityName: 'match-123',
channelId: 'CTrNsYq/Ee8AALLzHQaVlA==', // Channel ID from your server
})
```
When `channelId` is provided, the Live Activity subscribes to broadcast updates on iOS 18+. On iOS versions before 18, broadcast is unavailable and Voltra falls back to token-based Live Activity push updates.
### Sending broadcast updates
To update all Live Activities on a channel, send a POST request to APNs with:
- **Path:** `/4/broadcasts/apps/` (bundle ID without the `.push-type.liveactivity` suffix)
- **Header:** `apns-channel-id: `
- **Payload:** Same structure as individual updates—`event: "update"`, `content-state`, `timestamp`, etc.
For the full broadcast payload format and headers, see [Apple's broadcast push documentation](https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns).
### Broadcast vs. individual tokens
| Aspect | Individual tokens | Broadcast |
| ----------------------------- | -------------------------------- | -------------------------------- |
| Server sends | One notification per device | One notification per channel |
| `activityTokenReceived` event | Fires for each activity | Does not fire |
| Best for | Per-user content (orders, rides) | Shared content (scores, flights) |
| iOS version | 16.4+ | 18+ |
## Handling background execution
When Live Activity tokens change or need to be refreshed, iOS may wake your app in the background to deliver new tokens. The app has a limited window of time (typically around 30 seconds) to handle the event and communicate with your server before iOS may suspend or terminate the background process.
### Detecting background execution
Use the `useIsHeadless()` hook in React components to determine if your app is running in the background. The hook re-checks the native headless state when the app becomes active, which prevents the UI from staying hidden if the user opens the app after it was initially launched in the background.
```tsx
import { useIsHeadless } from '@use-voltra/ios-client'
function App() {
const isHeadless = useIsHeadless()
if (isHeadless) {
// App is running in background/headless mode
console.log('App launched in headless mode for token refresh')
} else {
// App is running in foreground
console.log('App launched in foreground')
}
}
```
The `isHeadless()` function is also available for synchronous checks outside React components, but prefer `useIsHeadless()` when deciding whether to render UI.
### Optimizing for background execution
When the app is launched in headless mode for token handling, **do not mount your main app component** to avoid excessive resource usage that could cause iOS to terminate the app before token updates are processed.
```typescript
// In your app's entry point (e.g., App.tsx, index.js)
import { useEffect } from 'react'
import { addVoltraListener, useIsHeadless } from '@use-voltra/ios-client'
function App() {
const isHeadless = useIsHeadless()
// Handle token events even in headless mode
useEffect(() => {
const subscription = addVoltraListener('activityTokenReceived', (event) => {
// Process token updates and send to server
sendTokenToServer(event)
})
return () => subscription.remove()
}, [])
// Only render the UI if not in headless mode
if (isHeadless) {
return null // Don't mount the app component tree
}
// Render your normal app UI
return
}
```
---
url: /ios/development/styling.md
---
# Styling
You can style Voltra components using React Native-style `style` props. The `style` prop works with a limited set of React Native properties that are automatically converted to SwiftUI modifiers under the hood.
## React Native style prop
Voltra supports a limited subset of React Native style properties. When you pass a `style` prop to a Voltra component, these properties are automatically converted to SwiftUI modifiers under the hood. This makes it easy to get started if you're familiar with React Native styling.
### Supported properties
The following React Native style properties are supported:
**Layout:**
- `width` - Fixed width (number values only, percentages are ignored)
- `height` - Fixed height (number values only, percentages are ignored)
- `flex` - Flex shorthand (follows Yoga's behavior). Positive values act as `flexGrow`, negative values act as `flexShrink`. Explicit `flexGrow`/`flexShrink` take precedence if both are specified.
- `flexGrow` - Flex grow factor. When > 0, allows the view to grow to fill available space (converts to flexible frame with `maxWidth`/`maxHeight` set to infinity)
- `flexShrink` - Flex shrink factor. When > 0, allows the view to shrink below its ideal size (sets `minWidth`/`minHeight` to 0)
- `padding` - Uniform padding on all edges
- `paddingTop`, `paddingBottom`, `paddingLeft`, `paddingRight` - Individual edge padding
- `paddingHorizontal`, `paddingVertical` - Horizontal and vertical padding
- `margin`, `marginTop`, `marginBottom`, `marginLeft`, `marginRight`, `marginHorizontal`, `marginVertical` - All margin properties are mapped to padding in SwiftUI
**Positioning:**
- `position` - Positioning mode: `'static'` (default), `'relative'`, or `'absolute'`
- `left` - Horizontal position coordinate (used with `position`)
- `top` - Vertical position coordinate (used with `position`)
- `zIndex` - Z-order of the element
**Style:**
- `backgroundColor` - Background color (hex strings, color names, or CSS gradient strings — see [Gradients](#gradients))
- `opacity` - Opacity value between 0 and 1
- `borderRadius` - Corner radius value
- `borderWidth` - Border width
- `borderColor` - Border color
**Shadow:**
- `shadowColor` - Shadow color
- `shadowOffset` - Shadow offset (`{ width: number, height: number }`)
- `shadowOpacity` - Shadow opacity
- `shadowRadius` - Shadow blur radius
**Text:**
- `fontSize` - Font size (maps to `font` modifier)
- `fontWeight` - Font weight (e.g., `'600'`, `'bold'`, `'regular'`)
- `fontFamily` - Custom font family name (see [Custom Fonts](#custom-fonts) section below)
- `color` - Text color (maps to `foregroundStyle` modifier)
- `letterSpacing` - Spacing between characters (maps to `kerning` modifier)
- `fontVariant` - Font variant array (e.g., `['small-caps', 'tabular-nums']`). Supported values:
- `'small-caps'` - Applies small caps styling (iOS 14+)
- `'tabular-nums'` - Applies monospaced digits (iOS 15+)
**Effects:**
- `overflow: 'hidden'` - Clips content to bounds (maps to `clipped` modifier)
### Flexbox Properties (Opt-in)
When using the `View` component or enabling flexbox mode on `VStack`/`HStack` with `layout="flex"`, additional flexbox properties become available via the `style` prop:
**Container Properties:**
- `flexDirection`: `'row'` | `'column'` - Main axis direction
- `alignItems`: `'flex-start'` | `'center'` | `'flex-end'` | `'stretch'` - Cross-axis alignment
- `justifyContent`: `'flex-start'` | `'center'` | `'flex-end'` | `'space-between'` | `'space-around'` | `'space-evenly'` - Main-axis distribution
- `gap`: number - Spacing between children along the main axis (one-axis only, no columnGap/rowGap)
**Child Properties:**
- `flex`: number - Shorthand for flexGrow/flexShrink
- `flexGrow`: number - Growth factor
- `flexShrink`: number - Shrink factor
- `flexBasis`: number | 'auto' - Base size
- `alignSelf`: Override parent's alignItems
:::note
Flexbox properties only work when flexbox layout is enabled. See [Flexbox Layout](/ios/development/flexbox-layout.md) for comprehensive documentation.
:::
### Limitations
Properties not listed above are ignored during rendering. This includes common React Native properties like:
- Flexbox layout properties (`flexDirection`, `justifyContent`, `alignItems`, `gap`, etc.) are only available when using flexbox layout. Use the `View` component or set `layout="flex"` on VStack/HStack to enable these properties. See [Flexbox Layout](/ios/development/flexbox-layout.md) for details. Properties `flex`, `flexGrow`, `flexShrink`, `flexBasis`, and `alignSelf` work on children inside flexbox containers.
- `columnGap`, `rowGap`, and `flexWrap` properties - Voltra only supports a single `gap` value along the main axis, and does not support wrapping
- Percentage-based widths and heights
- `right` and `bottom` positioning properties - Only `left` and `top` are supported
- Most text styling properties beyond `fontSize`, `fontWeight`, `fontFamily`, `color`, `letterSpacing`, and `fontVariant`
- **Live Update Overrides**: Certain styling properties (like `height` or `borderRadius` on progress bars) may be ignored when using live-updating features like `timerInterval` to ensure compatibility with smooth system animations.
:::tip Positioning in Voltra
Voltra supports CSS-style positioning with three modes:
- **`position: 'static'`** - Normal layout flow. `left` and `top` are ignored.
- **`position: 'relative'`** - Offsets the element from its natural position using `left` and `top`. The offset moves the element right (positive `left`) and down (positive `top`).
- **`position: 'absolute'`** (default when `left`/`top` provided) - Positions the element's **center** at the coordinates specified by `left` and `top`. This differs from CSS which positions from the top-left corner, but matches SwiftUI's native behavior.
**Note**: If you provide `left` or `top` without specifying `position`, it defaults to `'absolute'` for backward compatibility. To ignore `left`/`top`, explicitly set `position: 'static'`.
For most layouts, prefer using stack `alignment` props (`ZStack`, `VStack`, `HStack`) which provide better layout control. Use positioning for fine-tuning or overlays.
See the [Layout & Containers](/ios/components/layout.md) documentation for details on alignment.
:::
### Example
```tsx
import { Voltra } from '@use-voltra/ios'
const element = (
Styled Text
)
```
## Gradients
The `backgroundColor` style property accepts CSS gradient strings in addition to solid colors. Gradients are rendered natively using SwiftUI gradient modifiers and are automatically clipped by `borderRadius`.
Invalid or unsupported gradient syntax is parsed in **strict mode** and results in **no gradient background** (instead of silent best-effort fallback).
### Linear gradients
```tsx
// Named direction
// Diagonal
// Angle in degrees/radians/turns
```
Supported directions: `to right`, `to left`, `to top`, `to bottom`, `to top right`, `to top left`, `to bottom right`, `to bottom left`.
### Color stops
Explicit percentage positions are supported:
```tsx
```
When positions are omitted, Voltra applies CSS-like stop fix-up:
- First and last unspecified stops default to `0%` and `100%`.
- Unspecified stops between explicit anchors are linearly interpolated.
- Non-monotonic explicit positions are clamped forward.
### RGBA colors inside gradients
```tsx
```
### Radial gradients
```tsx
```
:::note
Radial gradients are rendered with geometry-aware radii computed from the view size (`closest-side`, `farthest-side`, `closest-corner`, `farthest-corner`).
For `ellipse`, SwiftUI does not provide native elliptical radial gradients. Voltra approximates ellipse behavior by scaling a circular radial gradient.
:::
### Conic gradients
```tsx
```
### With border radius
Gradients are clipped by `borderRadius` automatically — no extra configuration needed:
```tsx
Rounded gradient card
```
### Solid colors still work unchanged
Passing a plain color string to `backgroundColor` continues to work exactly as before:
```tsx
```
### Comparison with `` component
| Feature | `backgroundColor` gradient | `` component |
| ------------------------------------ | ------------------------------------------------ | ---------------------------- |
| CSS string syntax | ✓ | — |
| Named directions (`to right`) | ✓ (physical direction) | ✓ |
| Angle units | `deg`, `rad`, `turn` | ✓ via `{x,y}` |
| `{x, y}` coordinate control | — | ✓ |
| Stop positions | `linear/radial`: `%`, `conic`: `%` + angle units | ✓ via `locations` prop |
| Multi-position stops (`red 20% 40%`) | ✓ | — |
| `radial-gradient` | ✓ (`circle` / approximated `ellipse`) | — |
| `conic-gradient` | ✓ (`from` + `at`) | — |
| Strict invalid syntax handling | ✓ (fails closed) | — |
| Dithering | — | ✓ |
| Children layered on top | ✓ (as background) | ✓ (as container) |
Use `backgroundColor` gradient strings for convenience and web-style syntax. Use `` when you need precise `{x, y}` coordinate control or dithering.
### Scope and exclusions
- Supported core syntax: `linear-gradient(...)`, `radial-gradient(...)`, `conic-gradient(...)`.
- Not supported in this parser: `repeating-linear-gradient(...)`, `repeating-radial-gradient(...)`, `repeating-conic-gradient(...)`.
## Custom Fonts
Voltra supports custom fonts through the `fontFamily` style property.
### Adding Custom Fonts to Your Project
Voltra supports custom fonts in your Live Activities and Widgets through two main methods:
#### 1. Using Voltra's Font Configuration (Recommended)
The simplest way is to specify fonts directly in the Voltra plugin configuration. This follows the same pattern as `expo-font`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.com.example.app",
"fonts": ["./assets/fonts", "./assets/custom-font.ttf"]
}
]
]
}
}
```
The `fonts` array can include:
- Individual font files: `"./assets/fonts/CustomFont.ttf"`
- Entire directories: `"./assets/fonts"` (all fonts in the directory will be included)
- Supported formats: `.ttf`, `.otf`, `.woff`, `.woff2`
#### 2. Adding Fonts Manually in Xcode
For non-Expo projects or if you prefer manual configuration, you can add fonts directly to your Xcode project:
1. Add your font files (`.otf` or `.ttf`) to your Xcode project
2. Ensure they're included in the Live Activity target's "Copy Bundle Resources" build phase
3. Add the font file names to your `Info.plist` under the `UIAppFonts` key for Live Activity target
For detailed instructions, see Apple's documentation on [Applying custom fonts to text](https://developer.apple.com/documentation/swiftui/applying-custom-fonts-to-text).
### Using Custom Fonts
Once your fonts are added to the project, you can use them with the `fontFamily` style property:
```tsx
import { Voltra } from '@use-voltra/ios'
const element = (
Text with Custom Font
)
```
:::tip Font Family Names
The font family name you use in `fontFamily` should match the font's PostScript name, not the file name. You can find the PostScript name:
- In the Font Book app on macOS
- Using online tools like [fontdrop.info](https://fontdrop.info)
- In Xcode's font picker
For example, the font file `Inter-Bold.ttf` has the PostScript name `Inter-Bold`.
:::
### Font Weight with Custom Fonts
When using `fontFamily`, the `fontWeight` style property is ignored since you typically specify the exact font variant (e.g., `Inter-Bold`, `Inter-Regular`). If you need different weights, add multiple font files and specify the complete font name:
```tsx
// Regular weight
Regular Text
// Bold weight
Bold Text
```
### Example with Google Fonts
If you're using Google Fonts via `@expo-google-fonts`, they work seamlessly with Voltra:
```json
{
"expo": {
"plugins": [
[
"expo-font",
{
"fonts": ["node_modules/@expo-google-fonts/inter/Inter_400Regular.ttf"]
}
]
]
}
}
```
```tsx
Text using Google Font
```
:::note System Font Fallback
If `fontFamily` is not specified or the font cannot be found, Voltra will fall back to the system font with the specified `fontWeight`.
:::
---
url: /ios/development/widget-pre-rendering.md
---
# Widget Pre-rendering
Widget pre-rendering allows you to provide meaningful initial state for widgets before they are synced when the app runs for the first time.
## Configuration
Add `initialStatePath` to your widget configuration in `app.json`:
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Shows current weather conditions",
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/weather-initial.tsx"
}
]
}
]
]
}
}
```
### Localized initial states
Use a locale map so the config plugin pre-renders one bundle per language; iOS and Android pick the best match at runtime from the device locale (fallback order: preferred languages → language-only → `en` → first locale):
```json
"initialStatePath": {
"en": "./widgets/weather-initial.tsx",
"pl": "./widgets/weather-initial-pl.tsx"
}
```
## Implementation
Create a file at each configured path that exports a `WidgetVariants` object (or use the same file path for multiple locales if copy is identical):
```tsx
import { Voltra, type WidgetVariants } from '@use-voltra/ios'
const initialState: WidgetVariants = {
systemSmall: Content,
systemMedium: Content,
systemLarge: Content,
}
export default initialState
```
:::info
`initialStatePath` files are **not** part of your React Native app bundle. They run in Node.js during prebuild. Import `Voltra` and types from `@use-voltra/ios`, not `@use-voltra/ios-client` — the client package pulls in native modules that are unavailable in the prebuild sandbox.
:::
## Build Process
During build time, Voltra transpiles your widget files with Babel and executes them in a Node.js environment to generate initial states that are bundled into the iOS app.
## Limitations
- **Node.js Environment**: Code runs in Node.js, not in React Native or iOS
- **Babel Support**: TypeScript is supported via Babel transpilation
- **No Bundling**: Import resolution works for local files but there is no bundler involved
- **Voltra Imports**: Use `@use-voltra/ios` for JSX and types in `initialStatePath` files. Do not import from `@use-voltra/ios-client` or other React Native client APIs.
---
url: /ios/setup.md
---
# iOS Setup
Once you have [installed Voltra](/getting-started/installation.md), configure the iOS Expo plugin and use `@use-voltra/ios` for JSX.
:::info Minimum iOS Version
Voltra requires **iOS 16.4 or later**. Make sure your app's deployment target is set to iOS 16.4 or higher. See the [expo-build-properties](https://docs.expo.dev/build-reference/variables/) documentation for details on configuring your iOS deployment target.
:::
## 1. Configure the Expo Plugin
Add the iOS client plugin to your `app.json` or `app.config.js`. For iOS, you typically need to specify a `groupIdentifier` to enable data sharing between your main app and the Live Activity extension.
```json
{
"expo": {
"plugins": [
[
"@use-voltra/ios-client",
{
"groupIdentifier": "group.your.bundle.identifier",
"enablePushNotifications": true
}
]
]
}
}
```
For a complete list of plugin configuration options, see the [Plugin Configuration](/ios/api/plugin-configuration.md) documentation.
## 2. Regenerate your native project with Expo Prebuild
It's time to update your native project. Run the following command to regenerate it and install native dependencies for Voltra.
```sh [npx]
npx expo prebuild --platform ios
```
```sh [yarn]
yarn dlx expo prebuild --platform ios
```
```sh [pnpm]
pnpm dlx expo prebuild --platform ios
```
```sh [bunx]
bunx expo prebuild --platform ios
```
## 3. Create your first Live Activity
Now let's create a simple "Hello World" live activity. Create a new component:
```tsx
import React from 'react'
import { Voltra } from '@use-voltra/ios'
import { startLiveActivity } from '@use-voltra/ios-client'
function HelloWorldActivity() {
const activityUI = (
Hello World from Voltra!Your first live activity
)
const startActivity = async () => {
await startLiveActivity({
lockScreen: activityUI,
})
}
return (
Start Live Activity
)
}
```
You've created your first live activity with Voltra. The JSX you write gets automatically converted to SwiftUI and displayed natively on iOS.