diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt
index 109166f..6eac589 100644
--- a/android/src/main/java/voltra/generated/ShortNames.kt
+++ b/android/src/main/java/voltra/generated/ShortNames.kt
@@ -42,6 +42,7 @@ object ShortNames {
"cvl" to "currentValueLabel",
"dlu" to "deepLinkUrl",
"dv" to "defaultValue",
+ "dest" to "destination",
"dir" to "direction",
"dth" to "dither",
"dur" to "durationMs",
diff --git a/data/components.json b/data/components.json
index 32ac349..47d2529 100644
--- a/data/components.json
+++ b/data/components.json
@@ -62,6 +62,7 @@
"enabled": "en",
"id": "id",
"deepLinkUrl": "dlu",
+ "destination": "dest",
"padding": "pad",
"text": "txt",
"paddingVertical": "pv",
@@ -1172,6 +1173,19 @@
"description": "Text color"
}
}
+ },
+ {
+ "name": "Link",
+ "description": "Navigable link that opens a URL when tapped",
+ "swiftAvailability": "iOS 14.0, macOS 11.0",
+ "hasChildren": true,
+ "parameters": {
+ "destination": {
+ "type": "string",
+ "optional": false,
+ "description": "URL to navigate to when the link is tapped"
+ }
+ }
}
]
}
diff --git a/example/components/live-activities/DeepLinksLiveActivityUI.tsx b/example/components/live-activities/DeepLinksLiveActivityUI.tsx
new file mode 100644
index 0000000..563356b
--- /dev/null
+++ b/example/components/live-activities/DeepLinksLiveActivityUI.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import { Voltra } from 'voltra'
+
+export function DeepLinksLiveActivityUI() {
+ return (
+
+ {/* Link Examples */}
+
+ {/* Link with absolute URL */}
+
+
+
+
+ Order #123
+ Tap to view details
+
+
+
+
+
+
+ {/* Link with relative path */}
+
+
+
+
+ Settings
+ Manage preferences
+
+
+
+
+
+
+
+ )
+}
diff --git a/example/screens/live-activities/DeepLinksLiveActivity.tsx b/example/screens/live-activities/DeepLinksLiveActivity.tsx
new file mode 100644
index 0000000..f76fb57
--- /dev/null
+++ b/example/screens/live-activities/DeepLinksLiveActivity.tsx
@@ -0,0 +1,42 @@
+import React, { forwardRef, useEffect, useImperativeHandle } from 'react'
+import { useLiveActivity } from 'voltra/client'
+
+import { DeepLinksLiveActivityUI } from '../../components/live-activities/DeepLinksLiveActivityUI'
+import { LiveActivityExampleComponent } from './types'
+
+const DeepLinksLiveActivity: LiveActivityExampleComponent = forwardRef(
+ ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => {
+ const { start, update, end, isActive } = useLiveActivity(
+ {
+ lockScreen: {
+ content: ,
+ },
+ island: {
+ keylineTint: '#3B82F6',
+ },
+ },
+ {
+ activityName: 'deep-links',
+ autoUpdate,
+ autoStart,
+ deepLinkUrl: '/voltraui/deep-links',
+ }
+ )
+
+ useEffect(() => {
+ onIsActiveChange?.(isActive)
+ }, [isActive, onIsActiveChange])
+
+ useImperativeHandle(ref, () => ({
+ start,
+ update,
+ end,
+ }))
+
+ return null
+ }
+)
+
+DeepLinksLiveActivity.displayName = 'DeepLinksLiveActivity'
+
+export default DeepLinksLiveActivity
diff --git a/example/screens/live-activities/LiveActivitiesScreen.tsx b/example/screens/live-activities/LiveActivitiesScreen.tsx
index d3e8403..4572b54 100644
--- a/example/screens/live-activities/LiveActivitiesScreen.tsx
+++ b/example/screens/live-activities/LiveActivitiesScreen.tsx
@@ -8,6 +8,7 @@ import { Card } from '~/components/Card'
import { NotificationsCard } from '~/components/NotificationsCard'
import BasicLiveActivity from '~/screens/live-activities/BasicLiveActivity'
import CompassLiveActivity from '~/screens/live-activities/CompassLiveActivity'
+import DeepLinksLiveActivity from '~/screens/live-activities/DeepLinksLiveActivity'
import FlightLiveActivity from '~/screens/live-activities/FlightLiveActivity'
import LiquidGlassLiveActivity from '~/screens/live-activities/LiquidGlassLiveActivity'
import MusicPlayerLiveActivity from '~/screens/live-activities/MusicPlayerLiveActivity'
@@ -16,7 +17,15 @@ import WorkoutLiveActivity from '~/screens/live-activities/WorkoutLiveActivity'
import { LiveActivityExampleComponentRef } from './types'
-type ActivityKey = 'basic' | 'stylesheet' | 'glass' | 'flight' | 'workout' | 'compass' | 'supplementalFamilies'
+type ActivityKey =
+ | 'basic'
+ | 'stylesheet'
+ | 'glass'
+ | 'deepLinks'
+ | 'flight'
+ | 'workout'
+ | 'compass'
+ | 'supplementalFamilies'
const ACTIVITY_METADATA: Record = {
basic: {
@@ -31,6 +40,10 @@ const ACTIVITY_METADATA: Record(null)
const stylesheetRef = useRef(null)
const glassRef = useRef(null)
+ const deepLinksRef = useRef(null)
const flightRef = useRef(null)
const workoutRef = useRef(null)
const compassRef = useRef(null)
@@ -86,6 +102,7 @@ export default function LiveActivitiesScreen() {
basic: basicRef,
stylesheet: stylesheetRef,
glass: glassRef,
+ deepLinks: deepLinksRef,
flight: flightRef,
workout: workoutRef,
compass: compassRef,
@@ -113,6 +130,10 @@ export default function LiveActivitiesScreen() {
(isActive: boolean) => handleStatusChange('glass', isActive),
[handleStatusChange]
)
+ const handleDeepLinksStatusChange = useCallback(
+ (isActive: boolean) => handleStatusChange('deepLinks', isActive),
+ [handleStatusChange]
+ )
const handleFlightStatusChange = useCallback(
(isActive: boolean) => handleStatusChange('flight', isActive),
[handleStatusChange]
@@ -201,6 +222,7 @@ export default function LiveActivitiesScreen() {
+
diff --git a/ios/shared/ComponentTypeID.swift b/ios/shared/ComponentTypeID.swift
index a7a5a1c..5338223 100644
--- a/ios/shared/ComponentTypeID.swift
+++ b/ios/shared/ComponentTypeID.swift
@@ -29,6 +29,7 @@ public enum ComponentTypeID: Int, Codable {
case SPACER = 16
case DIVIDER = 17
case MASK = 18
+ case LINK = 19
/// Get the component name string for this ID
public var componentName: String {
@@ -71,6 +72,8 @@ public enum ComponentTypeID: Int, Codable {
return "Divider"
case .MASK:
return "Mask"
+ case .LINK:
+ return "Link"
}
}
@@ -98,6 +101,7 @@ public enum ComponentTypeID: Int, Codable {
case "Spacer": self = .SPACER
case "Divider": self = .DIVIDER
case "Mask": self = .MASK
+ case "Link": self = .LINK
default:
return nil
}
diff --git a/ios/shared/ShortNames.swift b/ios/shared/ShortNames.swift
index 17e5d10..762c0b6 100644
--- a/ios/shared/ShortNames.swift
+++ b/ios/shared/ShortNames.swift
@@ -39,6 +39,7 @@ public enum ShortNames {
"cvl": "currentValueLabel",
"dlu": "deepLinkUrl",
"dv": "defaultValue",
+ "dest": "destination",
"dir": "direction",
"dth": "dither",
"dur": "durationMs",
diff --git a/ios/shared/VoltraNode.swift b/ios/shared/VoltraNode.swift
index c95c26e..4d47213 100644
--- a/ios/shared/VoltraNode.swift
+++ b/ios/shared/VoltraNode.swift
@@ -122,6 +122,9 @@ struct VoltraElementView: View {
case "Button":
VoltraButton(element)
+ case "Link":
+ VoltraLink(element)
+
case "VStack":
VoltraVStack(element)
diff --git a/ios/ui/Generated/Parameters/LinkParameters.swift b/ios/ui/Generated/Parameters/LinkParameters.swift
new file mode 100644
index 0000000..fc1d4e2
--- /dev/null
+++ b/ios/ui/Generated/Parameters/LinkParameters.swift
@@ -0,0 +1,15 @@
+//
+// LinkParameters.swift
+
+// AUTO-GENERATED from data/components.json
+// DO NOT EDIT MANUALLY - Changes will be overwritten
+// Schema version: 1.0.0
+
+import Foundation
+
+/// Parameters for Link component
+/// Navigable link that opens a URL when tapped
+public struct LinkParameters: ComponentParameters {
+ /// URL to navigate to when the link is tapped
+ public let destination: String?
+}
diff --git a/ios/ui/Helpers/VoltraDeepLinkResolver.swift b/ios/ui/Helpers/VoltraDeepLinkResolver.swift
index 83c724f..9688003 100644
--- a/ios/ui/Helpers/VoltraDeepLinkResolver.swift
+++ b/ios/ui/Helpers/VoltraDeepLinkResolver.swift
@@ -18,12 +18,26 @@ enum VoltraDeepLinkResolver {
_ attributes: VoltraAttributes
) -> URL? {
if let raw = attributes.deepLinkUrl, !raw.isEmpty {
- if raw.contains("://"), let url = URL(string: raw) { return url }
- if let scheme = deepLinkScheme() {
- let path = raw.hasPrefix("/") ? raw : "/\(raw)"
- return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))")
- }
+ return resolveUrl(raw)
+ }
+ return nil
+ }
+
+ /// Resolves a URL string, supporting both absolute and relative paths
+ /// - Parameter raw: The URL string (e.g., "myapp://path", "/path", or "path")
+ /// - Returns: A resolved URL, or nil if invalid
+ static func resolveUrl(_ raw: String) -> URL? {
+ guard !raw.isEmpty else { return nil }
+
+ // If it's already an absolute URL, use it as-is
+ if raw.contains("://"), let url = URL(string: raw) { return url }
+
+ // Otherwise, prepend the app's URL scheme
+ if let scheme = deepLinkScheme() {
+ let path = raw.hasPrefix("/") ? raw : "/\(raw)"
+ return URL(string: "\(scheme)://\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))")
}
+
return nil
}
}
diff --git a/ios/ui/Views/VoltraLink.swift b/ios/ui/Views/VoltraLink.swift
new file mode 100644
index 0000000..f31f8e6
--- /dev/null
+++ b/ios/ui/Views/VoltraLink.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+public struct VoltraLink: VoltraView {
+ public typealias Parameters = LinkParameters
+ public let element: VoltraElement
+
+ public init(_ element: VoltraElement) {
+ self.element = element
+ }
+
+ public var body: some View {
+ if let urlString = params.destination,
+ let url = VoltraDeepLinkResolver.resolveUrl(urlString)
+ {
+ Link(destination: url) {
+ element.children ?? .empty
+ }
+ .applyStyle(element.style)
+ } else {
+ // Fallback: render children without link if destination is invalid
+ (element.children ?? .empty)
+ .applyStyle(element.style)
+ }
+ }
+}
diff --git a/src/jsx/Link.tsx b/src/jsx/Link.tsx
new file mode 100644
index 0000000..632f84b
--- /dev/null
+++ b/src/jsx/Link.tsx
@@ -0,0 +1,5 @@
+import { createVoltraComponent } from './createVoltraComponent.js'
+import type { LinkProps } from './props/Link.js'
+
+export type { LinkProps }
+export const Link = createVoltraComponent('Link')
diff --git a/src/jsx/primitives.ts b/src/jsx/primitives.ts
index 358a13a..dd0fae9 100644
--- a/src/jsx/primitives.ts
+++ b/src/jsx/primitives.ts
@@ -9,6 +9,7 @@ export * from './Image.js'
export * from './Label.js'
export * from './LinearGradient.js'
export * from './LinearProgressView.js'
+export * from './Link.js'
export * from './Mask.js'
export * from './Spacer.js'
export * from './Symbol.js'
diff --git a/src/jsx/props/Link.ts b/src/jsx/props/Link.ts
new file mode 100644
index 0000000..8021729
--- /dev/null
+++ b/src/jsx/props/Link.ts
@@ -0,0 +1,10 @@
+// 🤖 AUTO-GENERATED from data/components.json
+// DO NOT EDIT MANUALLY - Changes will be overwritten
+// Schema version: 1.0.0
+
+import type { VoltraBaseProps } from '../baseProps'
+
+export type LinkProps = VoltraBaseProps & {
+ /** URL to navigate to when the link is tapped */
+ destination: string
+}
diff --git a/src/payload/component-ids.ts b/src/payload/component-ids.ts
index bd70a34..9556609 100644
--- a/src/payload/component-ids.ts
+++ b/src/payload/component-ids.ts
@@ -27,6 +27,7 @@ export const COMPONENT_NAME_TO_ID: Record = {
Spacer: 16,
Divider: 17,
Mask: 18,
+ Link: 19,
}
/**
@@ -52,6 +53,7 @@ export const COMPONENT_ID_TO_NAME: Record = {
16: 'Spacer',
17: 'Divider',
18: 'Mask',
+ 19: 'Link',
}
/**
diff --git a/src/payload/short-names.ts b/src/payload/short-names.ts
index 1bfc33a..a992747 100644
--- a/src/payload/short-names.ts
+++ b/src/payload/short-names.ts
@@ -35,6 +35,7 @@ export const NAME_TO_SHORT: Record = {
currentValueLabel: 'cvl',
deepLinkUrl: 'dlu',
defaultValue: 'dv',
+ destination: 'dest',
direction: 'dir',
dither: 'dth',
durationMs: 'dur',
@@ -181,6 +182,7 @@ export const SHORT_TO_NAME: Record = {
cvl: 'currentValueLabel',
dlu: 'deepLinkUrl',
dv: 'defaultValue',
+ dest: 'destination',
dir: 'direction',
dth: 'dither',
dur: 'durationMs',
diff --git a/website/docs/ios/components/interactive.md b/website/docs/ios/components/interactive.md
index 41364bd..d4338b1 100644
--- a/website/docs/ios/components/interactive.md
+++ b/website/docs/ios/components/interactive.md
@@ -1,13 +1,177 @@
# Interactive Controls (iOS)
-User interface controls that respond to user interaction in Live Activities. These work via AppIntents and require iOS 17.0+ for interactivity.
+User interface controls that respond to user interaction in Live Activities.
-### Button
+---
+
+## 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 'voltra/client'
+
+const subscription = addVoltraListener('interaction', (event) => {
+ if (event.identifier === 'play-button') {
+ // Handle play action
+ }
+})
+```
-Triggers an action/intent when pressed.
+### Examples
+
+**Styled button:**
+
+```tsx
+
+ Save Changes
+
+```
+
+**Button with icon:**
+
+```tsx
+
+
+
+ Delete
+
+
+```
+
+**Compact button:**
+
+```tsx
+
+
+
+```
---
-### Toggle
+## 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 #123
+ Tap 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 'voltra/client'
-Toggles a boolean state via an intent.
+const subscription = addVoltraListener('interaction', (event) => {
+ if (event.identifier === 'notifications-toggle') {
+ // Handle toggle state change
+ }
+})
+```
diff --git a/website/docs/ios/components/overview.md b/website/docs/ios/components/overview.md
index 4812845..733921a 100644
--- a/website/docs/ios/components/overview.md
+++ b/website/docs/ios/components/overview.md
@@ -42,8 +42,8 @@ Components for displaying data and status information. This includes progress in
[See all data visualization & status components →](./status)
-### Interactive Controls
+### Interactive Controls & Navigation
-User interface controls that respond to user interaction. This category includes Button and Toggle components that enable interactive Live Activities.
+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 components →](./interactive)
+[See all interactive control & navigation components →](./interactive)
diff --git a/website/docs/ios/development/interactions.md b/website/docs/ios/development/interactions.md
index de28afb..dd796e3 100644
--- a/website/docs/ios/development/interactions.md
+++ b/website/docs/ios/development/interactions.md
@@ -8,11 +8,13 @@ ActivityKit provides a limited set of interactions with Live Activities. The onl
- **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.
-- **Buttons**: Interactive buttons that can trigger actions within your app. Buttons are implemented using AppIntents and work on both the Lock Screen and Dynamic Island.
+- **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+).
-- **Toggles**: Interactive toggle switches that allow users to change boolean states. Like buttons, toggles use AppIntents and are supported across all Live Activity contexts.
+- **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+).
-These interactions are powered by Apple's AppIntents framework, which enables Live Activities to communicate with your app even when it's not running.
+- **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
@@ -61,7 +63,9 @@ If you don't provide an explicit `id` prop, Voltra will automatically generate a
## Deep linking
-When users tap on the Live Activity itself (not on a button or toggle), your app can be launched via a deep link. Configure the deep link URL when starting a Live Activity:
+### 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 'voltra/client'
@@ -84,6 +88,30 @@ The deep link URL can be:
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: