nn-widgets
v0.1.20
Published
Expo config plugin for adding native widgets (iOS WidgetKit & Android App Widgets)
Maintainers
Readme
nn-widgets
Expo config plugin for native widgets — iOS WidgetKit & Android App Widgets.
Supports four widget types:
| Type | Description | Data Source |
| ---------- | ------------------------------------------------------- | --------------------------- |
| "image" | Full-bleed background image | Static asset at build time |
| "single" | Icon + Title + Subtitle + Value | updateWidget() at runtime |
| "list" | Vertical list of items with icon/title/description | updateWidget() at runtime |
| "grid" | Grid of items, each cell with icon on top + title below | updateWidget() at runtime |
Installation
npx expo install nn-widgetsQuick Start
1. Configure in app.json
{
"plugins": [
[
"nn-widgets",
{
"widgets": [
{
"name": "QuickActions",
"displayName": "Quick Actions",
"type": "list",
"deepLinkUrl": "myapp://widget",
"widgetFamilies": ["systemMedium", "systemLarge"],
"fallbackTitle": "Quick Actions",
"fallbackSubtitle": "Open app to set up",
"style": {
"backgroundColor": "#1E1E2E",
"titleColor": "#CDD6F4",
"subtitleColor": "#A6ADC8",
"accentColor": "#F38BA8"
}
}
],
"ios": {
"devTeam": "YOUR_TEAM_ID",
"useAppGroups": true
}
}
]
]
}2. Push data from your app
import { NNWidgets } from "nn-widgets";
await NNWidgets.updateWidget("QuickActions", {
items: [
{
icon: "star.fill",
title: "Morning Focus",
description: "25 min session",
deepLink: "myapp://session/123",
},
{
icon: { url: "brain", size: 28, radius: 14, backgroundColor: "#3B3B5C" },
title: { text: "Brainstorm", color: "#F38BA8", fontWeight: "bold" },
description: { text: "15 min", fontSize: "caption" },
},
],
});3. Rebuild
npx expo prebuild --clean
npx expo run:iosPlugin Configuration
widgets[] — Per-widget config
| Property | Type | Default | Description |
| ------------------ | ------------------------------- | ---------------------------------------------- | ------------------------------------------------- |
| name | string | required | Unique identifier (valid Swift/Kotlin identifier) |
| displayName | string | required | Name shown in widget gallery |
| description | string | "A widget for your app" | Description in widget gallery |
| type | "single" \| "list" \| "image" | auto-detect | Widget display type |
| deepLinkUrl | string | — | URL opened when widget is tapped |
| showAppIcon | boolean | true | Show app icon in widget UI |
| widgetFamilies | string[] | ["systemSmall","systemMedium","systemLarge"] | Supported sizes (iOS) |
| fallbackTitle | string | displayName | Title shown when no data set |
| fallbackSubtitle | string | "Open app to start" | Subtitle shown when no data set |
| image | object \| string | — | Background image paths (image type) |
| icons | Record<string, string> | — | Bundled icon images (name → path) |
| style | object | — | Widget-level colors |
| android | object | — | Android-specific overrides |
type auto-detection
- If
imageprop is set →"image" - Otherwise →
"single" - Set
type: "list"explicitly for list widgets - Set
type: "grid"explicitly for grid widgets
gridLayout (for type "grid")
Format: "COLUMNSxROWS" — controls the grid dimensions.
{ "gridLayout": "3x2" }| Value | Columns | Rows | Max Items |
| ------- | ------- | ---- | --------- |
| "2x2" | 2 | 2 | 4 |
| "3x2" | 3 | 2 | 6 |
| "4x2" | 4 | 2 | 8 |
| "2x3" | 2 | 3 | 6 |
If omitted, columns auto-adjust based on widget family:
systemSmall: 2 columnssystemMedium: 4 columnssystemLarge: 4 columns
image (for type "image")
{
"image": {
"small": "./assets/widgets/bg-small.png",
"medium": "./assets/widgets/bg-medium.png",
"large": "./assets/widgets/bg-large.png"
}
}Or a single image for all sizes: "image": "./assets/widgets/bg.png"
icons (for type "list" / "single")
Bundle icon images at build time. Referenced by name at runtime.
{
"icons": {
"brainstorm": "./assets/widgets/icons/brainstorm.png",
"meditation": "./assets/widgets/icons/meditation.png",
"focus": "./assets/widgets/icons/focus.png"
}
}At runtime, use the icon name:
{ icon: 'brainstorm', title: 'Morning Focus' }On iOS, the icon is checked: if a bundled image matches the name, it's used as Image(name). Otherwise, it's treated as an SF Symbol (Image(systemName:)).
style
{
"style": {
"backgroundColor": "#1E1E2E",
"titleColor": "#CDD6F4",
"subtitleColor": "#A6ADC8",
"accentColor": "#F38BA8"
}
}These provide widget-level defaults. Items can override per-item via WidgetItemText objects.
ios — Shared iOS config
| Property | Type | Default | Description |
| -------------------- | --------- | ------------------ | ---------------------------------------- |
| deploymentTarget | string | "17.0" | Minimum iOS version for widget extension |
| useAppGroups | boolean | true | Enable App Groups for data sharing |
| appGroupIdentifier | string | group.{bundleId} | Custom App Group ID |
| devTeam | string | — | Apple development team ID |
Important:
useAppGroups: truerequires the App Group capability to be configured in your Apple Developer account. For local development without provisioning, setuseAppGroups: false(widget won't receive data from the app).
android — Shared Android config
| Property | Type | Default | Description |
| -------------------- | -------- | ------------------------ | --------------------------------- |
| minSdkVersion | number | 26 | Minimum SDK for widget |
| updatePeriodMillis | number | 1800000 | Auto-update interval (min 30 min) |
| minWidth | number | 110 | Default widget width in dp |
| minHeight | number | 40 | Default widget height in dp |
| resizeMode | string | "horizontal\|vertical" | Resize behavior |
Runtime API
NNWidgets.updateWidget(widgetName, data)
Push structured data to a specific widget and reload its timeline.
import { NNWidgets } from "nn-widgets";
// List-type widget
await NNWidgets.updateWidget("SessionsList", {
items: [
{
icon: "star.fill",
title: "Morning Focus",
description: "25 min",
deepLink: "myapp://session/1",
},
{
icon: { url: "meditation", size: 28, radius: 14 },
title: { text: "Wind Down", color: "#CDD6F4", fontWeight: "bold" },
description: { text: "15 min", color: "#A6ADC8", fontSize: "caption" },
},
],
});
// Single-type widget
await NNWidgets.updateWidget("StatsWidget", {
title: "Focus Time",
subtitle: "This week",
value: 142,
});WidgetDataPayload
| Field | Type | Used by | Description |
| ---------- | ------------------ | ----------------------- | --------------------- |
| title | string | single, list (fallback) | Title text |
| subtitle | string | single, list (fallback) | Subtitle text |
| value | number | single | Numeric display value |
| items | WidgetListItem[] | list | Array of list items |
WidgetListItem
| Field | Type | Default | Description |
| ------------- | -------------------------- | ------------ | ------------------------------- |
| icon | string \| WidgetItemIcon | — | Left icon (name or config) |
| rightIcon | string \| WidgetItemIcon | — | Right icon (name or config) |
| title | string \| WidgetItemText | required | Title text or styled text |
| description | string \| WidgetItemText | — | Description text or styled text |
| deepLink | string | — | Per-item deep link URL |
WidgetItemIcon (object form)
| Field | Type | Default | Description |
| ----------------- | -------- | ------------ | ----------------------------------- |
| url | string | required | SF Symbol name or bundled icon name |
| size | number | 32 | Icon size in points/dp |
| radius | number | 0 | Corner radius (size/2 = circle) |
| backgroundColor | string | — | Background color (hex) |
Icon styling examples:
// Simple SF Symbol (default size 32, no background)
icon: "star.fill"
// Circular icon with background
icon: { url: "star.fill", size: 40, radius: 20, backgroundColor: "#FFD700" }
// Square icon with rounded corners
icon: { url: "brain", size: 36, radius: 8, backgroundColor: "#3B3B5C" }
// Right icon (e.g. chevron indicator)
rightIcon: { url: "chevron.right", size: 20, radius: 0 }
// Right icon as circular badge
rightIcon: { url: "checkmark.circle.fill", size: 24, radius: 12, backgroundColor: "#4CAF50" }Both icon (left) and rightIcon (right) accept the same format — either a string or a WidgetItemIcon object.
WidgetItemText (object form)
| Field | Type | Default | Description |
| ------------ | ------------------ | -------------------------------------------- | ---------------- |
| text | string | required | Text content |
| color | string | widget style | Text color (hex) |
| fontSize | WidgetFontSize | "subheadline" (title) / "caption" (desc) | Font size key |
| fontWeight | WidgetFontWeight | "semibold" (title) / "regular" (desc) | Font weight |
Font Size Reference
| Key | iOS (SwiftUI) | Android (sp) |
| ------------- | ----------------------- | ------------ |
| caption2 | .caption2 (11pt) | 11sp |
| caption | .caption (12pt) | 12sp |
| footnote | .footnote (13pt) | 13sp |
| subheadline | .subheadline (15pt) | 15sp |
| body | .body (17pt) | 17sp |
| headline | .headline (17pt bold) | 17sp bold |
| title3 | .title3 (20pt) | 20sp |
| title2 | .title2 (22pt) | 22sp |
| title | .title (28pt) | 28sp |
Font Weight Reference
| Key | iOS | Android |
| ---------- | ----------- | ----------------------- |
| regular | .regular | Typeface.NORMAL |
| medium | .medium | Typeface.NORMAL + 500 |
| semibold | .semibold | Typeface.BOLD |
| bold | .bold | Typeface.BOLD |
Max Items per Widget Size (iOS)
| Size | Max Items |
| ------------------ | --------- |
| systemSmall | 3 |
| systemMedium | 3 |
| systemLarge | 7 |
| systemExtraLarge | 7 |
Other API Methods
// Legacy flat key-value data (backward compatible)
await NNWidgets.setWidgetData({ key: "value", count: 42 });
// Get stored data
const data = await NNWidgets.getWidgetData();
// Reload a specific widget
await NNWidgets.reloadWidget({ widgetName: "MyWidget" });
// Reload all widgets
await NNWidgets.reloadWidget();
// Check support
const supported = NNWidgets.isSupported();How Data Flows
┌──────────────────────┐
│ React Native App │
│ │
│ NNWidgets.update │
│ Widget("List", { │
│ items: [...] │
│ }) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Native Module │
│ (NNWidgetsModule) │
│ │
│ Stores key-value │
│ pairs to shared │
│ storage │
│ │
│ iOS: UserDefaults │
│ (App Groups) │
│ Android: Shared │
│ Preferences │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Widget Extension │
│ (TimelineProvider) │
│ │
│ Reads items JSON │
│ from shared storage │
│ Parses & renders │
│ the widget UI │
└──────────────────────┘Key Naming Convention
Data is stored with the widget_ prefix:
| JS call | Storage key | Widget reads |
| ------------------------------------------ | --------------------- | --------------------------- |
| updateWidget("MyList", { title: "Hi" }) | widget_MyList_title | getString("MyList_title") |
| updateWidget("MyList", { items: [...] }) | widget_MyList_items | getJSON("MyList_items") |
| setWidgetData({ foo: "bar" }) | widget_foo | getString("foo") |
Full Example: app.json
{
"plugins": [
[
"nn-widgets",
{
"widgets": [
{
"name": "MainWidget",
"displayName": "My App",
"type": "single",
"deepLinkUrl": "myapp://widget",
"showAppIcon": true,
"widgetFamilies": ["systemSmall"]
},
{
"name": "RecentSessions",
"displayName": "Recent Sessions",
"type": "list",
"deepLinkUrl": "myapp://sessions",
"widgetFamilies": ["systemMedium", "systemLarge"],
"fallbackTitle": "Recent Sessions",
"fallbackSubtitle": "Open app to see sessions",
"icons": {
"focus": "./assets/widgets/icons/focus.png",
"relax": "./assets/widgets/icons/relax.png"
},
"style": {
"backgroundColor": "#1E1E2E",
"titleColor": "#CDD6F4",
"subtitleColor": "#A6ADC8",
"accentColor": "#F38BA8"
}
},
{
"name": "BannerWidget",
"displayName": "Banner",
"deepLinkUrl": "myapp://banner",
"widgetFamilies": ["systemMedium"],
"image": {
"medium": "./assets/widgets/banner.png"
}
},
{
"name": "QuickGrid",
"displayName": "Quick Grid",
"type": "grid",
"gridLayout": "3x2",
"deepLinkUrl": "myapp://grid",
"widgetFamilies": ["systemMedium", "systemLarge"],
"fallbackTitle": "Quick Grid",
"fallbackSubtitle": "Open app to set up",
"style": {
"backgroundColor": "#1A1A2E",
"titleColor": "#E0E0E0",
"accentColor": "#BB86FC"
}
}
],
"ios": {
"devTeam": "YOUR_TEAM_ID",
"useAppGroups": true,
"appGroupIdentifier": "group.com.myapp"
}
}
]
]
}Full Example: Pushing data from a screen
import React, { useEffect } from 'react';
import { NNWidgets } from 'nn-widgets';
function SessionsScreen({ sessions }) {
useEffect(() => {
// Update list widget with styled icons
NNWidgets.updateWidget('RecentSessions', {
items: sessions.slice(0, 7).map((s) => ({
icon: { url: s.type, size: 32, radius: 8, backgroundColor: '#3B3B5C' },
title: { text: s.name, fontWeight: 'semibold' },
description: { text: `${s.duration} min`, fontSize: 'caption' },
rightIcon: { url: 'chevron.right', size: 16 },
deepLink: `myapp://session/${s.id}`,
})),
});
}, [sessions]);
return (/* ... */);
}Full Example: Grid widget data
// Grid widget with 3x2 layout (3 columns, 2 rows, max 6 items)
await NNWidgets.updateWidget('QuickGrid', {
items: [
{
icon: { url: 'star.fill', size: 40, radius: 20, backgroundColor: '#FFD700' },
title: 'Favorites',
deepLink: 'myapp://favorites',
},
{
icon: { url: 'clock.fill', size: 40, radius: 20, backgroundColor: '#4A90D9' },
title: 'Recent',
deepLink: 'myapp://recent',
},
{
icon: { url: 'bolt.fill', size: 40, radius: 20, backgroundColor: '#FF6B6B' },
title: 'Quick Start',
deepLink: 'myapp://quick',
},
{
icon: { url: 'person.2.fill', size: 40, radius: 20, backgroundColor: '#66BB6A' },
title: 'Team',
deepLink: 'myapp://team',
},
{
icon: { url: 'chart.bar.fill', size: 40, radius: 20, backgroundColor: '#AB47BC' },
title: 'Stats',
deepLink: 'myapp://stats',
},
{
icon: { url: 'gearshape.fill', size: 40, radius: 20, backgroundColor: '#78909C' },
title: 'Settings',
deepLink: 'myapp://settings',
},
],
});Troubleshooting
Widget shows fallback text ("Open app to start")
- The app hasn't called
updateWidget()yet, oruseAppGroupsisfalse - Ensure
useAppGroups: trueiniosconfig and the App Group is provisioned
Widget doesn't update after calling updateWidget()
updateWidget()automatically callsreloadWidget()after storing data- iOS may delay widget updates. Force refresh by removing and re-adding the widget
Build error: "App Group not configured"
- Add the App Group capability in Apple Developer portal
- The App Group ID must match
appGroupIdentifier(default:group.{bundleIdentifier}) - For local dev without provisioning, set
useAppGroups: false
Icons not showing
- Bundled icons: ensure paths in
iconsconfig are correct and files exist - SF Symbols: use exact symbol names (e.g.,
"star.fill", not"star") - Android: icon names are lowercased for drawable resources
Requirements
- Expo SDK 53+
- iOS 17.0+ (for WidgetKit)
- Android API 26+ (for App Widgets)
License
MIT
