diff --git a/example/__tests__/ios/__image_snapshots__/ios/absolute-positioning-basic.png b/example/__tests__/ios/__image_snapshots__/ios/absolute-positioning-basic.png
new file mode 100644
index 0000000..b779010
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/absolute-positioning-basic.png differ
diff --git a/example/__tests__/ios/__image_snapshots__/ios/absolute-positioning-corners.png b/example/__tests__/ios/__image_snapshots__/ios/absolute-positioning-corners.png
new file mode 100644
index 0000000..b0e2944
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/absolute-positioning-corners.png differ
diff --git a/example/__tests__/ios/__image_snapshots__/ios/badge-overlay.png b/example/__tests__/ios/__image_snapshots__/ios/badge-overlay.png
new file mode 100644
index 0000000..bb459d3
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/badge-overlay.png differ
diff --git a/example/__tests__/ios/__image_snapshots__/ios/relative-positioning-basic.png b/example/__tests__/ios/__image_snapshots__/ios/relative-positioning-basic.png
new file mode 100644
index 0000000..193deaa
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/relative-positioning-basic.png differ
diff --git a/example/__tests__/ios/__image_snapshots__/ios/relative-positioning-negative.png b/example/__tests__/ios/__image_snapshots__/ios/relative-positioning-negative.png
new file mode 100644
index 0000000..9900a49
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/relative-positioning-negative.png differ
diff --git a/example/__tests__/ios/__image_snapshots__/ios/static-positioning.png b/example/__tests__/ios/__image_snapshots__/ios/static-positioning.png
new file mode 100644
index 0000000..536cf65
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/static-positioning.png differ
diff --git a/example/__tests__/ios/__image_snapshots__/ios/z-index-layering.png b/example/__tests__/ios/__image_snapshots__/ios/z-index-layering.png
new file mode 100644
index 0000000..4078552
Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/z-index-layering.png differ
diff --git a/example/__tests__/ios/positioning.harness.tsx b/example/__tests__/ios/positioning.harness.tsx
new file mode 100644
index 0000000..7adc62a
--- /dev/null
+++ b/example/__tests__/ios/positioning.harness.tsx
@@ -0,0 +1,46 @@
+import { screen } from '@react-native-harness/ui'
+import { ComponentType } from 'react'
+import { View } from 'react-native'
+import { describe, expect, render, test } from 'react-native-harness'
+
+import {
+ AbsolutePositioningBasicExample,
+ AbsolutePositioningCornersExample,
+ BadgeOverlayExample,
+ RelativePositioningBasicExample,
+ RelativePositioningNegativeExample,
+ StaticPositioningExample,
+ ZIndexLayeringExample,
+} from '../../screens/testing-grounds/positioning/PositioningExamples'
+
+const snapshotTest = (name: string, Component: ComponentType<{ testID?: string }>) => {
+ return test(`should match snapshot for ${name}`, async () => {
+ await render(
+
+
+
+ )
+
+ const previewElement = await screen.findByTestId('preview')
+ const screenshot = await screen.screenshot(previewElement)
+ await expect(screenshot).toMatchImageSnapshot({
+ name: name.toLowerCase().replace(/\s+/g, '-'),
+ })
+ })
+}
+
+describe('Positioning snapshots', () => {
+ snapshotTest('Static Positioning', StaticPositioningExample)
+
+ snapshotTest('Relative Positioning Basic', RelativePositioningBasicExample)
+
+ snapshotTest('Relative Positioning Negative', RelativePositioningNegativeExample)
+
+ snapshotTest('Absolute Positioning Basic', AbsolutePositioningBasicExample)
+
+ snapshotTest('Absolute Positioning Corners', AbsolutePositioningCornersExample)
+
+ snapshotTest('Z-Index Layering', ZIndexLayeringExample)
+
+ snapshotTest('Badge Overlay', BadgeOverlayExample)
+})
diff --git a/example/app/testing-grounds/positioning.tsx b/example/app/testing-grounds/positioning.tsx
new file mode 100644
index 0000000..6d9e54e
--- /dev/null
+++ b/example/app/testing-grounds/positioning.tsx
@@ -0,0 +1,5 @@
+import PositioningScreen from '~/screens/testing-grounds/positioning/PositioningScreen'
+
+export default function PositioningIndex() {
+ return
+}
diff --git a/example/screens/testing-grounds/TestingGroundsScreen.tsx b/example/screens/testing-grounds/TestingGroundsScreen.tsx
index e2f5484..1b25908 100644
--- a/example/screens/testing-grounds/TestingGroundsScreen.tsx
+++ b/example/screens/testing-grounds/TestingGroundsScreen.tsx
@@ -20,6 +20,13 @@ const TESTING_GROUNDS_SECTIONS = [
'Explore Voltra styling properties including padding, margins, colors, borders, shadows, and typography.',
route: '/testing-grounds/styling',
},
+ {
+ id: 'positioning',
+ title: 'Positioning',
+ description:
+ 'Learn about static, relative, and absolute positioning modes. See how left, top, and zIndex properties work with visual examples.',
+ route: '/testing-grounds/positioning',
+ },
{
id: 'components',
title: 'Components',
diff --git a/example/screens/testing-grounds/positioning/PositioningExamples.tsx b/example/screens/testing-grounds/positioning/PositioningExamples.tsx
new file mode 100644
index 0000000..e2ddaea
--- /dev/null
+++ b/example/screens/testing-grounds/positioning/PositioningExamples.tsx
@@ -0,0 +1,361 @@
+import React from 'react'
+import { Voltra } from 'voltra'
+import { VoltraView } from 'voltra/client'
+
+export type PositioningExampleProps = {
+ testID?: string
+}
+
+export function StaticPositioningExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+ {/* This box has left/top but NO position - should be centered and ignore left/top */}
+
+ Static
+ (Centered)
+
+
+
+ )
+}
+
+export function RelativePositioningBasicExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+ {/* Reference box showing natural position (top-left) */}
+
+ Natural
+
+
+ {/* Relatively positioned box - offset from natural position */}
+
+ Relative
+ +20, +10
+
+
+
+ )
+}
+
+export function RelativePositioningNegativeExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+ {/* Reference box at center */}
+
+ Natural
+
+
+ {/* Relatively positioned with negative offset */}
+
+ Relative
+ -15, -15
+
+
+
+ )
+}
+
+export function AbsolutePositioningBasicExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+ {/* Crosshair marker at (50, 50) to show center point */}
+
+
+ {/* Absolutely positioned box - center should be at (50, 50) */}
+
+ Absolute
+ @50, 50
+
+
+
+ )
+}
+
+export function AbsolutePositioningCornersExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+
+ {/* Top-left box */}
+
+ 30,30
+
+
+ {/* Bottom-right box */}
+
+ 200,170
+
+
+ {/* Center box */}
+
+ 115,100
+
+
+ {/* Center marker */}
+
+
+ {/* Bottom-right corner marker */}
+
+
+ {/* Top-left corner marker */}
+
+
+
+
+ )
+}
+
+export function ZIndexLayeringExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+ {/* Bottom layer (zIndex: 1) */}
+
+ z: 1
+
+
+ {/* Middle layer (zIndex: 2) */}
+
+ z: 2
+
+
+ {/* Top layer (zIndex: 3) */}
+
+ z: 3
+
+
+
+ )
+}
+
+export function BadgeOverlayExample({ testID }: PositioningExampleProps) {
+ return (
+
+
+ {/* Avatar with badge overlay */}
+
+
+
+ {/* Notification Badge - Absolutely positioned on avatar */}
+
+ 3
+
+
+
+ {/* Info */}
+
+ John Doe
+ Software Engineer
+
+
+
+ )
+}
diff --git a/example/screens/testing-grounds/positioning/PositioningScreen.tsx b/example/screens/testing-grounds/positioning/PositioningScreen.tsx
new file mode 100644
index 0000000..c62fd2e
--- /dev/null
+++ b/example/screens/testing-grounds/positioning/PositioningScreen.tsx
@@ -0,0 +1,136 @@
+import { Link } from 'expo-router'
+import React from 'react'
+import { FlatList, StyleSheet, Text, View } from 'react-native'
+
+import { Button } from '~/components/Button'
+import { Card } from '~/components/Card'
+
+import {
+ AbsolutePositioningBasicExample,
+ AbsolutePositioningCornersExample,
+ BadgeOverlayExample,
+ RelativePositioningBasicExample,
+ RelativePositioningNegativeExample,
+ StaticPositioningExample,
+ ZIndexLayeringExample,
+} from './PositioningExamples'
+
+const POSITIONING_DATA = [
+ {
+ id: 'static-default',
+ title: 'Static Positioning (Default)',
+ description: 'When position is not set or set to "static", left and top are ignored. Box should stay centered.',
+ Component: StaticPositioningExample,
+ },
+ {
+ id: 'relative-basic',
+ title: 'Relative Positioning - Basic',
+ description:
+ 'position: "relative" offsets the box from its natural position. left: 20, top: 10 moves it right and down.',
+ Component: RelativePositioningBasicExample,
+ },
+ {
+ id: 'relative-negative',
+ title: 'Relative Positioning - Negative Offset',
+ description: 'Negative values move the box left (negative left) and up (negative top) from its natural position.',
+ Component: RelativePositioningNegativeExample,
+ },
+ {
+ id: 'absolute-basic',
+ title: 'Absolute Positioning - Center-Based',
+ description:
+ 'position: "absolute" places the CENTER of the box at the coordinates. left: 50, top: 50 means center at (50, 50).',
+ Component: AbsolutePositioningBasicExample,
+ },
+ {
+ id: 'absolute-corners',
+ title: 'Absolute Positioning - Four Corners',
+ description: 'Demonstrating absolute positioning at different coordinates. Red dots mark the center points.',
+ Component: AbsolutePositioningCornersExample,
+ },
+ {
+ id: 'zindex-layering',
+ title: 'Z-Index Layering',
+ description: 'Using zIndex with positioning to control stacking order.',
+ Component: ZIndexLayeringExample,
+ },
+ {
+ id: 'practical-overlay',
+ title: 'Practical Example - Badge Overlay',
+ description: 'Using absolute positioning to create a notification badge on a profile card.',
+ Component: BadgeOverlayExample,
+ },
+]
+
+export default function PositioningScreen() {
+ const renderHeader = () => (
+ <>
+ Positioning Examples
+
+ Explore Voltra's positioning modes: static (default), relative (offset from natural position), and absolute
+ (center-based coordinates). Red dots mark reference points in absolute positioning examples.
+
+ >
+ )
+
+ const renderItem = ({ item }: { item: (typeof POSITIONING_DATA)[0] }) => {
+ const { Component } = item
+ return (
+
+ {item.title}
+ {item.description}
+
+
+ )
+ }
+
+ const renderFooter = () => (
+
+
+
+
+
+ )
+
+ return (
+
+ item.id}
+ ListHeaderComponent={renderHeader}
+ renderItem={renderItem}
+ ListFooterComponent={renderFooter}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingVertical: 24,
+ },
+ heading: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ subheading: {
+ fontSize: 14,
+ lineHeight: 20,
+ color: '#CBD5F5',
+ marginBottom: 8,
+ },
+ footer: {
+ marginTop: 24,
+ alignItems: 'center',
+ },
+})
diff --git a/ios/ui/Style/CompositeStyle.swift b/ios/ui/Style/CompositeStyle.swift
index 64b2231..ffb1881 100644
--- a/ios/ui/Style/CompositeStyle.swift
+++ b/ios/ui/Style/CompositeStyle.swift
@@ -18,7 +18,12 @@ struct CompositeStyleModifier: ViewModifier {
content.background(.clear).padding(margin)
}
- .voltraIfLet(layout.position) { content, position in
+ // Apply relative positioning (offset from natural position)
+ .voltraIfLet(layout.relativeOffset) { content, offset in
+ content.offset(x: offset.x, y: offset.y)
+ }
+ // Apply absolute positioning (center-based)
+ .voltraIfLet(layout.absolutePosition) { content, position in
content.position(x: position.x, y: position.y)
}
.voltraIfLet(layout.zIndex) { content, zIndex in
diff --git a/ios/ui/Style/LayoutStyle.swift b/ios/ui/Style/LayoutStyle.swift
index 1778b12..2236164 100644
--- a/ios/ui/Style/LayoutStyle.swift
+++ b/ios/ui/Style/LayoutStyle.swift
@@ -26,7 +26,8 @@ struct LayoutStyle {
var margin: EdgeInsets?
// 6. Positioning
- var position: CGPoint? // x,y coordinates
+ var absolutePosition: CGPoint? // for position: 'absolute'
+ var relativeOffset: CGPoint? // for position: 'relative'
var zIndex: Double?
}
diff --git a/ios/ui/Style/StyleConverter.swift b/ios/ui/Style/StyleConverter.swift
index 5d1e484..b411686 100644
--- a/ios/ui/Style/StyleConverter.swift
+++ b/ios/ui/Style/StyleConverter.swift
@@ -15,14 +15,33 @@ enum StyleConverter {
// Only set layoutPriority when flex is active; nil means "don't apply modifier".
let priority: Double? = finalFlex > 0 ? 1.0 : nil
- // Position parsing (offsetX/offsetY -> CGPoint)
- var position: CGPoint?
- if let offsetX = JSStyleParser.number(js["left"]), let offsetY = JSStyleParser.number(js["top"]) {
- position = CGPoint(x: offsetX, y: offsetY)
- } else if let offsetX = JSStyleParser.number(js["left"]) {
- position = CGPoint(x: offsetX, y: 0)
- } else if let offsetY = JSStyleParser.number(js["top"]) {
- position = CGPoint(x: 0, y: offsetY)
+ // Position parsing with mode support
+ let left = JSStyleParser.number(js["left"])
+ let top = JSStyleParser.number(js["top"])
+
+ var absolutePosition: CGPoint?
+ var relativeOffset: CGPoint?
+
+ // Only apply positioning if left or top are provided
+ if left != nil || top != nil {
+ let x = left ?? 0
+ let y = top ?? 0
+
+ // Default to 'absolute' if position mode not specified (backward compatibility)
+ let positionMode = js["position"] as? String ?? "absolute"
+
+ switch positionMode.lowercased() {
+ case "absolute":
+ absolutePosition = CGPoint(x: x, y: y)
+ case "relative":
+ relativeOffset = CGPoint(x: x, y: y)
+ case "static":
+ // Do nothing - ignore left/top
+ break
+ default:
+ // Unknown position value - ignore
+ break
+ }
}
// zIndex: only set if explicitly provided in JS
@@ -47,7 +66,8 @@ enum StyleConverter {
margin: JSStyleParser.parseInsets(from: js, prefix: "margin"),
// Positioning
- position: position,
+ absolutePosition: absolutePosition,
+ relativeOffset: relativeOffset,
zIndex: zIndex.map { Double($0) }
)
}
diff --git a/src/components/VoltraView.tsx b/src/components/VoltraView.tsx
index 9d4491c..30e7839 100644
--- a/src/components/VoltraView.tsx
+++ b/src/components/VoltraView.tsx
@@ -1,6 +1,6 @@
import { requireNativeView } from 'expo'
import React, { ReactNode, useEffect, useMemo } from 'react'
-import { StyleProp, ViewStyle } from 'react-native'
+import { StyleProp, View, ViewStyle } from 'react-native'
import { addVoltraListener, VoltraInteractionEvent } from '../events.js'
import { renderVoltraVariantToJson } from '../renderer/index.js'
@@ -10,6 +10,10 @@ const NativeVoltraView = requireNativeView('VoltraModule')
// Generate a unique ID for views that don't have one
const generateViewId = () => `voltra-view-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
+const voltraViewStyle: ViewStyle = {
+ flex: 1,
+}
+
export type VoltraViewProps = {
/**
* Unique identifier for this view instance.
@@ -30,6 +34,10 @@ export type VoltraViewProps = {
* Events are filtered by this view's id (source).
*/
onInteraction?: (event: VoltraInteractionEvent) => void
+ /**
+ * Test ID for the view
+ */
+ testID?: string
}
/**
@@ -45,7 +53,7 @@ export type VoltraViewProps = {
*
* ```
*/
-export function VoltraView({ id, children, style, onInteraction }: VoltraViewProps) {
+export function VoltraView({ id, children, style, onInteraction, testID }: VoltraViewProps) {
// Generate a stable ID if not provided
const viewId = useMemo(() => id || generateViewId(), [id])
@@ -72,5 +80,9 @@ export function VoltraView({ id, children, style, onInteraction }: VoltraViewPro
return () => subscription.remove()
}, [viewId, onInteraction])
- return
+ return (
+
+
+
+ )
}
diff --git a/src/styles/types.ts b/src/styles/types.ts
index 9dba3f8..4936159 100644
--- a/src/styles/types.ts
+++ b/src/styles/types.ts
@@ -37,6 +37,7 @@ export type VoltraViewStyle = Pick<
| 'aspectRatio'
| 'left'
| 'top'
+ | 'position'
| 'zIndex'
| 'transform'
> & {
diff --git a/website/docs/ios/development/styling.md b/website/docs/ios/development/styling.md
index 99add86..997266a 100644
--- a/website/docs/ios/development/styling.md
+++ b/website/docs/ios/development/styling.md
@@ -24,8 +24,10 @@ The following React Native style properties are supported:
**Positioning:**
-- `offsetX` - Horizontal offset from the element's natural position (positive = right, negative = left)
-- `offsetY` - Vertical offset from the element's natural position (positive = down, negative = up)
+- `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:**
@@ -64,15 +66,20 @@ Properties not listed above are ignored during rendering. This includes common R
- Most flexbox layout properties (`flexDirection`, `justifyContent`, `alignItems`, etc.) - Note: `flex`, `flexGrow`, and `flexShrink` are supported
- `gap` and spacing properties
- Percentage-based widths and heights
-- CSS-style positioning (`position`, `top`, `left`, `right`, `bottom`) - Use SwiftUI-native positioning instead (see below)
+- `right` and `bottom` positioning properties - Only `left` and `top` are supported
- Most text styling properties beyond `fontSize`, `fontWeight`, `fontFamily`, `color`, `letterSpacing`, and `fontVariant`
:::tip Positioning in Voltra
-Voltra uses SwiftUI's native positioning model instead of CSS-style absolute/relative positioning:
+Voltra supports CSS-style positioning with three modes:
-1. **Use stack `alignment` props** - `ZStack`, `VStack`, and `HStack` all support an `alignment` prop that positions all children
-2. **Use `offsetX`/`offsetY` styles** - Fine-tune individual element positions with offset
+- **`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](../components/layout) documentation for details on alignment.