diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..0528c78
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,35 @@
+// swift-tools-version:5.9
+import PackageDescription
+
+let package = Package(
+ name: "react-two",
+ platforms: [
+ .iOS(.v13),
+ .tvOS(.v13),
+ .visionOS(.v1)
+ ],
+ products: [
+ .library(
+ name: "react-two",
+ targets: ["react-two"]),
+ ],
+ dependencies: [
+ // React Native dependencies are typically handled by the host application
+ // when using SPM for React Native modules.
+ ],
+ targets: [
+ .target(
+ name: "react-two",
+ dependencies: [],
+ path: "ios",
+ exclude: [],
+ sources: [
+ "TwoViewManager.swift",
+ "TwoViewManager.m",
+ "TwoMetalView.swift",
+ "TwoRenderer.swift",
+ "Shaders.metal" // Also add the shaders!
+ ]
+ )
+ ]
+)
diff --git a/docs/native-bindings.md b/docs/native-bindings.md
new file mode 100644
index 0000000..2bc1507
--- /dev/null
+++ b/docs/native-bindings.md
@@ -0,0 +1,100 @@
+# Native Bindings Guide for react-two.js
+
+This guide explains how to implement native rendering bindings for `react-two.js`, enabling high-performance 2D graphics on iOS (via Metal) and Android (via Canvas/OpenGL) while keeping the declarative React API.
+
+## Architecture Overview
+
+`react-two.js` runs in the JavaScript thread. To render on native platforms, we need a bridge that:
+1. **Intercepts Two.js commands**: The JS side generates a scene graph.
+2. **Serializes Rendering Data**: We need to send draw calls (paths, fills, strokes) to the native side.
+3. **Native Renderer**: A native view (Swift/Kotlin) that interprets these commands and draws them.
+
+### The "Headless" Approach
+
+Two.js supports running in a headless environment (no DOM). We can leverage this to use Two.js as a scene graph manager and math engine, while offloading the actual pixel pushing to the native GPU.
+
+## iOS Implementation (Metal)
+
+For iOS, we use Swift Package Manager (SPM) to manage the native module.
+
+### 1. Create the Native Module
+
+We have provided a `Package.swift` file in the root. This defines the `react-two` library.
+
+**Swift Package Manager Setup**:
+1. In your React Native project (if using Expo with CNG or a bare workflow), you can add the local package or git dependency.
+2. For local development, drag the `react-two.js` folder (or just the `Package.swift`) into your Xcode project's "Package Dependencies".
+
+**The View Manager (`ios/TwoViewManager.swift`)**:
+This Swift class exposes the `TwoView` to JavaScript.
+
+```swift
+@objc(TwoViewManager)
+class TwoViewManager: RCTViewManager {
+ override func view() -> UIView! {
+ return TwoMetalView()
+ }
+
+ override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+}
+```
+
+### 2. Create the Metal View
+
+`TwoMetalView` (`ios/TwoMetalView.swift`) handles the Metal rendering pipeline.
+
+```swift
+import MetalKit
+
+class TwoMetalView: MTKView {
+ // ... Metal setup code ...
+
+ // Receive draw commands from JS
+ @objc var drawCommands: NSDictionary = [:] {
+ didSet {
+ self.setNeedsDisplay()
+ }
+ }
+
+ override func draw(_ rect: CGRect) {
+ // 1. Create Command Buffer
+ // 2. Parse drawCommands
+ // 3. Encode render commands (draw primitives)
+ // 4. Commit buffer
+ }
+}
+```
+
+### 3. The JS-Native Bridge
+
+In your React Native app, you will use the `react-two.js/native` exports.
+
+```typescript
+import { Canvas, Circle } from 'react-two.js/native';
+
+function App() {
+ return (
+
+ );
+}
+```
+
+**Crucial Step**: The `Provider` in `lib/native/Provider.tsx` uses `requireNativeComponent` to connect to the native view. You must ensure your native module is correctly linked so React Native can find `TwoView`.
+
+## Android Implementation
+
+The concept is similar for Android.
+
+1. Create a `SimpleViewManager` in Java/Kotlin.
+2. Create a custom `View` that overrides `onDraw(Canvas canvas)`.
+3. Receive the serialized JSON commands and map them to `canvas.drawCircle`, `canvas.drawPath`, etc.
+
+## Optimization Tips
+
+- **Batch Updates**: Don't send updates for every single property change. Batch them per frame.
+- **Binary Format**: For complex scenes, JSON serialization is slow. Consider using ArrayBuffers or JSI (JavaScript Interface) for direct memory access between JS and C++ (Native).
+- **Reanimated**: Integrate with `react-native-reanimated` for running animations on the UI thread to avoid bridge traffic.
diff --git a/ios/Shaders.metal b/ios/Shaders.metal
new file mode 100644
index 0000000..d741dca
--- /dev/null
+++ b/ios/Shaders.metal
@@ -0,0 +1,36 @@
+#include
+using namespace metal;
+
+struct VertexIn {
+ float2 position [[attribute(0)]];
+ float4 color [[attribute(1)]];
+};
+
+struct VertexOut {
+ float4 position [[position]];
+ float4 color;
+};
+
+vertex VertexOut vertex_main(
+ const device VertexIn* vertices [[buffer(0)]],
+ uint vertexId [[vertex_id]],
+ constant float2& viewportSize [[buffer(1)]]
+) {
+ VertexOut out;
+
+ // Convert from pixel coordinates (0..width, 0..height) to NDC (-1..1, -1..1)
+ float2 pixelSpacePosition = vertices[vertexId].position;
+ float2 ndcPosition = float2(
+ (pixelSpacePosition.x / viewportSize.x) * 2.0 - 1.0,
+ -(pixelSpacePosition.y / viewportSize.y) * 2.0 + 1.0 // Flip Y for Metal
+ );
+
+ out.position = float4(ndcPosition, 0.0, 1.0);
+ out.color = vertices[vertexId].color;
+
+ return out;
+}
+
+fragment float4 fragment_main(VertexOut in [[stage_in]]) {
+ return in.color;
+}
diff --git a/ios/TwoMetalView.swift b/ios/TwoMetalView.swift
new file mode 100644
index 0000000..50e093d
--- /dev/null
+++ b/ios/TwoMetalView.swift
@@ -0,0 +1,38 @@
+import MetalKit
+import React
+
+class TwoMetalView: MTKView {
+
+ var renderer: TwoRenderer?
+
+ @objc var drawCommands: NSDictionary = [:] {
+ didSet {
+ if let commands = drawCommands as? [String: Any] {
+ renderer?.updateDrawCommands(commands)
+ }
+ self.setNeedsDisplay()
+ }
+ }
+
+ init() {
+ super.init(frame: .zero, device: MTLCreateSystemDefaultDevice())
+
+ guard let device = self.device else {
+ print("Metal is not supported on this device")
+ return
+ }
+
+ self.renderer = TwoRenderer(device: device, view: self)
+ self.delegate = self.renderer
+
+ // Configure the view
+ self.colorPixelFormat = .bgra8Unorm
+ self.framebufferOnly = true
+ self.enableSetNeedsDisplay = true // Only draw when requested
+ self.isPaused = true // We control the loop via drawCommands updates
+ }
+
+ required init(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/ios/TwoRenderer.swift b/ios/TwoRenderer.swift
new file mode 100644
index 0000000..9b772e5
--- /dev/null
+++ b/ios/TwoRenderer.swift
@@ -0,0 +1,254 @@
+import MetalKit
+
+struct Vertex {
+ var position: SIMD2
+ var color: SIMD4
+}
+
+struct DrawCall {
+ var type: MTLPrimitiveType
+ var vertexStart: Int
+ var vertexCount: Int
+}
+
+class TwoRenderer: NSObject, MTKViewDelegate {
+
+ let device: MTLDevice
+ let commandQueue: MTLCommandQueue?
+ var pipelineState: MTLRenderPipelineState?
+ var viewportSize: SIMD2 = .zero
+
+ // Data to render
+ var vertices: [Vertex] = []
+ var drawCalls: [DrawCall] = []
+
+ init(device: MTLDevice, view: MTKView) {
+ self.device = device
+ self.commandQueue = device.makeCommandQueue()
+ super.init()
+
+ buildPipelineState(view: view)
+ }
+
+ private func buildPipelineState(view: MTKView) {
+ guard let library = device.makeDefaultLibrary() else { return }
+
+ let vertexFunction = library.makeFunction(name: "vertex_main")
+ let fragmentFunction = library.makeFunction(name: "fragment_main")
+
+ let pipelineDescriptor = MTLRenderPipelineDescriptor()
+ pipelineDescriptor.label = "TwoRenderPipeline"
+ pipelineDescriptor.vertexFunction = vertexFunction
+ pipelineDescriptor.fragmentFunction = fragmentFunction
+ pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
+
+ // Enable alpha blending
+ pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
+ pipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add
+ pipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add
+ pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
+ pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
+ pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
+ pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
+
+ do {
+ pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
+ } catch {
+ print("Failed to create pipeline state: \(error)")
+ }
+ }
+
+ func updateDrawCommands(_ commands: [String: Any]) {
+ var newVertices: [Vertex] = []
+ var newDrawCalls: [DrawCall] = []
+
+ // Start with identity matrix
+ let identity = matrix_float3x3(diagonal: SIMD3(1, 1, 1))
+
+ if let children = commands["children"] as? [[String: Any]] {
+ for child in children {
+ parseNode(child, transform: identity, vertices: &newVertices, drawCalls: &newDrawCalls)
+ }
+ }
+
+ self.vertices = newVertices
+ self.drawCalls = newDrawCalls
+ }
+
+ private func parseNode(_ node: [String: Any], transform: matrix_float3x3, vertices: inout [Vertex], drawCalls: inout [DrawCall]) {
+ // Calculate local transform
+ // We expect translation, rotation, scale in the JSON
+ // Default to identity values if missing
+
+ let tx = Float((node["translation"] as? [String: Any])?["x"] as? Double ?? 0)
+ let ty = Float((node["translation"] as? [String: Any])?["y"] as? Double ?? 0)
+ let rotation = Float(node["rotation"] as? Double ?? 0)
+ let sx = Float((node["scale"] as? [String: Any])?["x"] as? Double ?? 1)
+ let sy = Float((node["scale"] as? [String: Any])?["y"] as? Double ?? 1)
+
+ // Construct local matrix
+ // 2D Affine Transform in 3x3 matrices:
+ // [ cx -sy tx ]
+ // [ sx cy ty ]
+ // [ 0 0 1 ]
+ // But SIMD matches column-major usually.
+ // Let's build T * R * S
+
+ // Translation
+ let T = matrix_float3x3(columns: (
+ SIMD3(1, 0, 0),
+ SIMD3(0, 1, 0),
+ SIMD3(tx, ty, 1)
+ ))
+
+ // Rotation
+ let c = cos(rotation)
+ let s = sin(rotation)
+ let R = matrix_float3x3(columns: (
+ SIMD3(c, s, 0),
+ SIMD3(-s, c, 0),
+ SIMD3(0, 0, 1)
+ ))
+
+ // Scale
+ let S = matrix_float3x3(columns: (
+ SIMD3(sx, 0, 0),
+ SIMD3(0, sy, 0),
+ SIMD3(0, 0, 1)
+ ))
+
+ // Combine: Parent * T * R * S
+ // Note: matrix_float3x3 multiplication order depends on vector convention.
+ // If we use v * M, then M = S * R * T * Parent?
+ // Let's assume M * v, so M = Parent * T * R * S
+ let localTransform = matrix_multiply(T, matrix_multiply(R, S))
+ let globalTransform = matrix_multiply(transform, localTransform)
+
+ // Check if it's a group
+ if let children = node["children"] as? [[String: Any]] {
+ for child in children {
+ parseNode(child, transform: globalTransform, vertices: &vertices, drawCalls: &drawCalls)
+ }
+ return
+ }
+
+ // Handle Shape
+ guard let rawVertices = node["vertices"] as? [[String: Any]], !rawVertices.isEmpty else {
+ return
+ }
+
+ let worldPoints = rawVertices.compactMap { v -> SIMD2? in
+ guard let x = v["x"] as? Double, let y = v["y"] as? Double else { return nil }
+ let localPos = SIMD3(Float(x), Float(y), 1)
+ let worldPos = matrix_multiply(globalTransform, localPos)
+ return SIMD2(worldPos.x, worldPos.y)
+ }
+
+ guard worldPoints.count >= 2 else { return }
+
+ // 1. Fill
+ if let fill = node["fill"] as? String, fill != "none" {
+ let fillColor = parseHexColor(fill, opacity: Float(node["opacity"] as? Double ?? 1.0))
+ let startIdx = vertices.count
+
+ // Simple Fan Triangulation (assumes convex)
+ for i in 1.. 0 {
+ drawCalls.append(DrawCall(type: .triangle, vertexStart: startIdx, vertexCount: count))
+ }
+ }
+
+ // 2. Stroke
+ if let stroke = node["stroke"] as? String, stroke != "none" {
+ let strokeColor = parseHexColor(stroke, opacity: Float(node["opacity"] as? Double ?? 1.0))
+ let startIdx = vertices.count
+
+ for p in worldPoints {
+ vertices.append(Vertex(position: p, color: strokeColor))
+ }
+
+ if let closed = node["closed"] as? Bool, closed {
+ vertices.append(Vertex(position: worldPoints[0], color: strokeColor))
+ }
+
+ let count = vertices.count - startIdx
+ if count > 0 {
+ // Use lineStrip for now.
+ // For proper thickness, we'd need to generate triangles based on linewidth.
+ drawCalls.append(DrawCall(type: .lineStrip, vertexStart: startIdx, vertexCount: count))
+ }
+ }
+ }
+
+ private func parseHexColor(_ hex: String, opacity: Float = 1.0) -> SIMD4 {
+ var cString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+
+ if cString.hasPrefix("#") {
+ cString.remove(at: cString.startIndex)
+ }
+
+ if cString.count == 3 {
+ var expanded = ""
+ for char in cString {
+ expanded.append(char)
+ expanded.append(char)
+ }
+ cString = expanded
+ }
+
+ if cString.count != 6 {
+ return SIMD4(0.5, 0.5, 0.5, opacity) // Gray fallback
+ }
+
+ var rgbValue: UInt64 = 0
+ Scanner(string: cString).scanHexInt64(&rgbValue)
+
+ return SIMD4(
+ Float((rgbValue & 0xFF0000) >> 16) / 255.0,
+ Float((rgbValue & 0x00FF00) >> 8) / 255.0,
+ Float(rgbValue & 0x0000FF) / 255.0,
+ opacity
+ )
+ }
+
+ func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
+ viewportSize = SIMD2(Float(size.width), Float(size.height))
+ }
+
+ func draw(in view: MTKView) {
+ guard !vertices.isEmpty,
+ let pipelineState = pipelineState,
+ let drawable = view.currentDrawable,
+ let renderPassDescriptor = view.currentRenderPassDescriptor,
+ let commandQueue = commandQueue,
+ let commandBuffer = commandQueue.makeCommandBuffer(),
+ let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
+ return
+ }
+
+ renderEncoder.setRenderPipelineState(pipelineState)
+ renderEncoder.setViewport(MTLViewport(originX: 0, originY: 0, width: Double(viewportSize.x), height: Double(viewportSize.y), znear: 0, zfar: 1))
+
+ // Send viewport size
+ renderEncoder.setVertexBytes(&viewportSize, length: MemoryLayout>.stride, index: 1)
+
+ // Send vertices
+ // In a real app, use a MTLBuffer for better performance
+ renderEncoder.setVertexBytes(vertices, length: vertices.count * MemoryLayout.stride, index: 0)
+
+ // Execute draw calls
+ for call in drawCalls {
+ renderEncoder.drawPrimitives(type: call.type, vertexStart: call.vertexStart, vertexCount: call.vertexCount)
+ }
+
+ renderEncoder.endEncoding()
+ commandBuffer.present(drawable)
+ commandBuffer.commit()
+ }
+}
diff --git a/ios/TwoViewManager.m b/ios/TwoViewManager.m
new file mode 100644
index 0000000..73bfbbc
--- /dev/null
+++ b/ios/TwoViewManager.m
@@ -0,0 +1,7 @@
+#import
+
+@interface RCT_EXTERN_MODULE(TwoViewManager, RCTViewManager)
+
+RCT_EXPORT_VIEW_PROPERTY(drawCommands, NSDictionary)
+
+@end
diff --git a/ios/TwoViewManager.swift b/ios/TwoViewManager.swift
new file mode 100644
index 0000000..38a6813
--- /dev/null
+++ b/ios/TwoViewManager.swift
@@ -0,0 +1,13 @@
+import React
+
+@objc(TwoViewManager)
+class TwoViewManager: RCTViewManager {
+
+ override func view() -> (TwoMetalView) {
+ return TwoMetalView()
+ }
+
+ @objc override static func requiresMainQueueSetup() -> Bool {
+ return true
+ }
+}
diff --git a/lib/native.ts b/lib/native.ts
new file mode 100644
index 0000000..62e11cf
--- /dev/null
+++ b/lib/native.ts
@@ -0,0 +1,27 @@
+export { Provider as Canvas } from './native/Provider';
+export * from './Context';
+export * from './Properties';
+
+// Re-export all shapes from the main library
+// These are platform agnostic as they operate on the Two.js instance
+export { ArcSegment } from './ArcSegment';
+export { Circle } from './Circle';
+export { Ellipse } from './Ellipse';
+export { Group } from './Group';
+export { Image } from './Image';
+export { ImageSequence } from './ImageSequence';
+export { Line } from './Line';
+export { LinearGradient } from './LinearGradient';
+export { Path } from './Path';
+export { Points } from './Points';
+export { Polygon } from './Polygon';
+export { RadialGradient } from './RadialGradient';
+export { Rectangle } from './Rectangle';
+export { RoundedRectangle } from './RoundedRectangle';
+export { Sprite } from './Sprite';
+export { Star } from './Star';
+export { Text } from './Text';
+export { Texture } from './Texture';
+
+// Export types
+export type * from './native/types';
diff --git a/lib/native/Provider.tsx b/lib/native/Provider.tsx
new file mode 100644
index 0000000..6dfddcb
--- /dev/null
+++ b/lib/native/Provider.tsx
@@ -0,0 +1,218 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import Two from 'two.js';
+import type { Shape } from 'two.js/src/shape';
+import type { Group } from 'two.js/src/group';
+import { requireNativeComponent, ViewProps } from 'react-native';
+import { Context, useTwo } from '../Context';
+import type { EventHandlers } from '../Events';
+
+// Define the native component interface
+interface NativeTwoViewProps extends ViewProps {
+ drawCommands?: Record;
+}
+
+// Require the native component
+// We use a try-catch or conditional to avoid crashing if the native module isn't linked yet
+// during development or testing.
+const NativeTwoView = requireNativeComponent('TwoView');
+
+type TwoConstructorProps = ConstructorParameters[0];
+type TwoConstructorPropsKeys = NonNullable;
+type ComponentProps = React.PropsWithChildren & {
+ style?: ViewProps['style'];
+};
+
+/**
+ * A React Native compatible Provider for Two.js.
+ * Renders a NativeTwoView backed by Metal (iOS) or Custom View (Android).
+ */
+export const Provider: React.FC = (props) => {
+ const { two, parent } = useTwo();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const nativeRef = useRef(null);
+
+ // Store registered shapes for event handling (future implementation)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const eventShapes = useRef