diff --git a/README.md b/README.md
index 7a23380..5844bc1 100644
--- a/README.md
+++ b/README.md
@@ -427,6 +427,58 @@ Setting `useHardwareLayer` rasterizes the view into a GPU texture for the durati
No-op on iOS where Core Animation already composites off the main thread.
+## Benchmarks
+
+The example app includes a benchmark that measures per-frame animation overhead across different approaches. All approaches run the same animation (translateX loop, linear, 2s) on a configurable number of views.
+
+### Android (release build, emulator, M4 MacBook Pro)
+
+UI thread time per frame: anim + layout + draw (ms). Lower is better.
+
+
+
+
+Detailed numbers
+
+| Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated |
+|-------|--------|------|---------------|---------------------|----------------|----------------------|-------------|
+| 10 | Avg | 0.21 | 1.15 | 0.75 | 0.99 | 0.45 | 0.36 |
+| 10 | P95 | 0.33 | 1.70 | 1.53 | 1.44 | 0.80 | 0.62 |
+| 10 | P99 | 0.48 | 1.94 | 2.26 | 1.62 | 1.35 | 0.98 |
+| 100 | Avg | 0.36 | 2.71 | 1.81 | 2.19 | 1.01 | 0.71 |
+| 100 | P95 | 0.56 | 3.09 | 2.29 | 2.67 | 1.91 | 1.08 |
+| 100 | P99 | 0.71 | 3.20 | 2.63 | 2.97 | 2.25 | 1.36 |
+| 500 | Avg | 0.60 | 8.31 | 5.37 | 5.50 | 2.37 | 1.60 |
+| 500 | P95 | 0.75 | 9.26 | 6.36 | 6.34 | 2.86 | 1.88 |
+| 500 | P99 | 0.87 | 9.59 | 6.89 | 6.88 | 3.22 | 3.84 |
+
+
+
+### iOS (release build, simulator, iPhone 16 Pro, M4 MacBook Pro)
+
+Display link callback time per frame (ms). Lower is better.
+
+
+
+
+Detailed numbers
+
+| Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated |
+|-------|--------|------|---------------|---------------------|----------------|----------------------|-------------|
+| 10 | Avg | 0.01 | 1.33 | 1.08 | 1.06 | 0.63 | 0.83 |
+| 10 | P95 | 0.02 | 1.67 | 1.59 | 1.34 | 1.01 | 1.18 |
+| 10 | P99 | 0.03 | 1.90 | 1.68 | 1.50 | 1.08 | 1.31 |
+| 100 | Avg | 0.01 | 3.72 | 3.33 | 2.71 | 2.48 | 3.32 |
+| 100 | P95 | 0.01 | 5.21 | 4.50 | 3.83 | 3.39 | 4.28 |
+| 100 | P99 | 0.02 | 5.68 | 4.75 | 4.91 | 3.79 | 4.55 |
+| 500 | Avg | 0.01 | 6.84 | 6.54 | 4.16 | 3.70 | 4.91 |
+| 500 | P95 | 0.01 | 7.69 | 7.32 | 4.59 | 4.22 | 5.66 |
+| 500 | P99 | 0.02 | 8.10 | 7.45 | 4.71 | 4.33 | 5.89 |
+
+
+
+Ease stays near zero because animations run entirely on platform APIs. On iOS, Core Animation runs on a separate render server process off the main thread, which is why Ease shows ~0ms. On Android, ObjectAnimator runs on the UI thread but is significantly lighter than other approaches. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/).
+
## How It Works
`EaseView` is a native Fabric component. The JS side flattens your `animate` and `transition` props into flat native props. When those props change, the native view:
diff --git a/example/.gitignore b/example/.gitignore
index 03d5ee3..0b9ce13 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -7,3 +7,7 @@ web-build/
# Expo prebuild generated dirs
android/
ios/
+
+# Keep local modules native code
+!modules/**/android/
+!modules/**/ios/
diff --git a/example/modules/frame-metrics/android/build.gradle b/example/modules/frame-metrics/android/build.gradle
new file mode 100644
index 0000000..b8049de
--- /dev/null
+++ b/example/modules/frame-metrics/android/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+android {
+ namespace "expo.modules.framemetrics"
+ defaultConfig {
+ versionCode 1
+ versionName "1.0.0"
+ }
+}
diff --git a/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt
new file mode 100644
index 0000000..aee368b
--- /dev/null
+++ b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt
@@ -0,0 +1,87 @@
+package expo.modules.framemetrics
+
+import android.os.Handler
+import android.os.HandlerThread
+import android.view.FrameMetrics
+import android.view.Window
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import java.util.concurrent.CopyOnWriteArrayList
+
+private data class FrameSample(
+ val animationMs: Double,
+ val layoutMs: Double,
+ val drawMs: Double,
+)
+
+class FrameMetricsModule : Module() {
+ private val frameSamples = CopyOnWriteArrayList()
+ private var listener: Window.OnFrameMetricsAvailableListener? = null
+ private var handlerThread: HandlerThread? = null
+
+ override fun definition() = ModuleDefinition {
+ Name("FrameMetrics")
+
+ Function("startCollecting") {
+ frameSamples.clear()
+
+ val activity = appContext.currentActivity
+ ?: throw IllegalStateException("No activity")
+
+ val thread = HandlerThread("FrameMetricsHandler").also { it.start() }
+ handlerThread = thread
+ val handler = Handler(thread.looper)
+
+ val l = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
+ frameSamples.add(FrameSample(
+ animationMs = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) / 1_000_000.0,
+ layoutMs = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) / 1_000_000.0,
+ drawMs = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) / 1_000_000.0,
+ ))
+ }
+ listener = l
+
+ activity.runOnUiThread {
+ activity.window.addOnFrameMetricsAvailableListener(l, handler)
+ }
+ }
+
+ Function("stopCollecting") {
+ val activity = appContext.currentActivity
+ val l = listener
+ if (activity != null && l != null) {
+ activity.runOnUiThread {
+ try {
+ activity.window.removeOnFrameMetricsAvailableListener(l)
+ } catch (_: Exception) {}
+ }
+ }
+ listener = null
+ handlerThread?.quitSafely()
+ handlerThread = null
+
+ val samples = frameSamples.toList()
+ if (samples.isEmpty()) {
+ return@Function mapOf(
+ "avgUiThreadTime" to 0.0,
+ "p95UiThreadTime" to 0.0,
+ "p99UiThreadTime" to 0.0,
+ "avgAnimationTime" to 0.0,
+ "avgLayoutTime" to 0.0,
+ "avgDrawTime" to 0.0,
+ )
+ }
+
+ val uiThreadDurations = samples.map { it.animationMs + it.layoutMs + it.drawMs }.sorted()
+
+ mapOf(
+ "avgUiThreadTime" to uiThreadDurations.average(),
+ "p95UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.95).toInt().coerceAtMost(uiThreadDurations.size - 1)],
+ "p99UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.99).toInt().coerceAtMost(uiThreadDurations.size - 1)],
+ "avgAnimationTime" to samples.map { it.animationMs }.average(),
+ "avgLayoutTime" to samples.map { it.layoutMs }.average(),
+ "avgDrawTime" to samples.map { it.drawMs }.average(),
+ )
+ }
+ }
+}
diff --git a/example/modules/frame-metrics/expo-module.config.json b/example/modules/frame-metrics/expo-module.config.json
new file mode 100644
index 0000000..198c3a0
--- /dev/null
+++ b/example/modules/frame-metrics/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["FrameMetricsModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.framemetrics.FrameMetricsModule"]
+ }
+}
diff --git a/example/modules/frame-metrics/ios/FrameMetrics.podspec b/example/modules/frame-metrics/ios/FrameMetrics.podspec
new file mode 100644
index 0000000..03f3374
--- /dev/null
+++ b/example/modules/frame-metrics/ios/FrameMetrics.podspec
@@ -0,0 +1,22 @@
+Pod::Spec.new do |s|
+ s.name = 'FrameMetrics'
+ s.version = '1.0.0'
+ s.summary = 'Frame metrics collection module'
+ s.description = 'Expo module for collecting frame timing metrics'
+ s.license = 'MIT'
+ s.author = 'Janic Duplessis'
+ s.homepage = 'https://github.com/AppAndFlow/react-native-ease'
+ s.platforms = { :ios => '15.1' }
+ s.swift_version = '5.9'
+ s.source = { git: 'https://github.com/AppAndFlow/react-native-ease.git' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.source_files = "**/*.{h,m,swift}"
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+end
diff --git a/example/modules/frame-metrics/ios/FrameMetricsModule.swift b/example/modules/frame-metrics/ios/FrameMetricsModule.swift
new file mode 100644
index 0000000..6ddac6d
--- /dev/null
+++ b/example/modules/frame-metrics/ios/FrameMetricsModule.swift
@@ -0,0 +1,139 @@
+import ExpoModulesCore
+import QuartzCore
+import ObjectiveC
+
+// MARK: - Display Link Swizzle
+
+/// Aggregates per-frame callback durations across all swizzled CADisplayLinks.
+final class FrameCallbackCollector {
+ static let shared = FrameCallbackCollector()
+
+ var isCollecting = false
+ private var currentFrameTimestamp: CFTimeInterval = 0
+ private var currentFrameWorkMs: Double = 0
+ private(set) var frameWorkTimes: [Double] = []
+
+ func reset() {
+ isCollecting = false
+ currentFrameTimestamp = 0
+ currentFrameWorkMs = 0
+ frameWorkTimes = []
+ }
+
+ func finalize() {
+ if currentFrameWorkMs > 0 {
+ frameWorkTimes.append(currentFrameWorkMs)
+ }
+ isCollecting = false
+ }
+
+ func recordCallback(timestamp: CFTimeInterval, durationMs: Double) {
+ guard isCollecting else { return }
+ if timestamp != currentFrameTimestamp {
+ if currentFrameTimestamp > 0 {
+ frameWorkTimes.append(currentFrameWorkMs)
+ }
+ currentFrameTimestamp = timestamp
+ currentFrameWorkMs = 0
+ }
+ currentFrameWorkMs += durationMs
+ }
+}
+
+/// Wraps an original CADisplayLink target to measure callback duration.
+final class DisplayLinkProxy: NSObject {
+ let originalTarget: AnyObject
+ let originalSelector: Selector
+
+ init(target: AnyObject, selector: Selector) {
+ self.originalTarget = target
+ self.originalSelector = selector
+ super.init()
+ }
+
+ @objc func proxyCallback(_ displayLink: CADisplayLink) {
+ let start = CACurrentMediaTime()
+ _ = originalTarget.perform(originalSelector, with: displayLink)
+ let elapsed = (CACurrentMediaTime() - start) * 1000.0
+ FrameCallbackCollector.shared.recordCallback(
+ timestamp: displayLink.timestamp,
+ durationMs: elapsed
+ )
+ }
+}
+
+private var proxyAssocKey: UInt8 = 0
+private var originalFactoryIMP: IMP?
+private var didSwizzle = false
+
+private func swizzleDisplayLinkFactory() {
+ guard !didSwizzle else { return }
+ didSwizzle = true
+
+ let metaCls: AnyClass = object_getClass(CADisplayLink.self)!
+ let sel = NSSelectorFromString("displayLinkWithTarget:selector:")
+ guard let method = class_getInstanceMethod(metaCls, sel) else { return }
+
+ originalFactoryIMP = method_getImplementation(method)
+
+ typealias OrigFunc = @convention(c) (AnyClass, Selector, AnyObject, Selector) -> CADisplayLink
+
+ let block: @convention(block) (AnyObject, AnyObject, Selector) -> CADisplayLink = {
+ _, target, selector in
+ let proxy = DisplayLinkProxy(target: target, selector: selector)
+ let orig = unsafeBitCast(originalFactoryIMP!, to: OrigFunc.self)
+ let factorySel = NSSelectorFromString("displayLinkWithTarget:selector:")
+ let link = orig(
+ CADisplayLink.self, factorySel, proxy,
+ #selector(DisplayLinkProxy.proxyCallback(_:)))
+ // Keep proxy alive via associated object
+ objc_setAssociatedObject(link, &proxyAssocKey, proxy, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ return link
+ }
+
+ let imp = imp_implementationWithBlock(unsafeBitCast(block, to: AnyObject.self))
+ method_setImplementation(method, imp)
+}
+
+// MARK: - Module
+
+public final class FrameMetricsModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("FrameMetrics")
+
+ OnCreate {
+ swizzleDisplayLinkFactory()
+ }
+
+ Function("startCollecting") {
+ let collector = FrameCallbackCollector.shared
+ collector.reset()
+ collector.isCollecting = true
+ }
+
+ Function("stopCollecting") { () -> [String: Any] in
+ let collector = FrameCallbackCollector.shared
+ collector.finalize()
+ let workTimes = collector.frameWorkTimes
+
+ guard !workTimes.isEmpty else {
+ return [
+ "avgUiThreadTime": 0,
+ "p95UiThreadTime": 0,
+ "p99UiThreadTime": 0,
+ ]
+ }
+
+ let sorted = workTimes.sorted()
+ let avg = sorted.reduce(0, +) / Double(sorted.count)
+ let p95 = sorted[min(Int(Double(sorted.count) * 0.95), sorted.count - 1)]
+ let p99 = sorted[min(Int(Double(sorted.count) * 0.99), sorted.count - 1)]
+
+ return [
+ "avgUiThreadTime": avg,
+ "p95UiThreadTime": p95,
+ "p99UiThreadTime": p99,
+ ]
+ }
+ }
+}
diff --git a/example/modules/frame-metrics/src/index.ts b/example/modules/frame-metrics/src/index.ts
new file mode 100644
index 0000000..f23039f
--- /dev/null
+++ b/example/modules/frame-metrics/src/index.ts
@@ -0,0 +1,35 @@
+import { NativeModule, requireNativeModule, Platform } from 'expo-modules-core';
+
+interface FrameMetricsResult {
+ /** Average UI/main thread work time per frame (ms) */
+ avgUiThreadTime: number;
+ /** P95 UI/main thread work time (ms) */
+ p95UiThreadTime: number;
+ /** P99 UI/main thread work time (ms) */
+ p99UiThreadTime: number;
+ /** Average time spent evaluating animators (Android only) */
+ avgAnimationTime?: number;
+ /** Average layout/measure time (Android only) */
+ avgLayoutTime?: number;
+ /** Average draw time (Android only) */
+ avgDrawTime?: number;
+}
+
+declare class FrameMetricsModuleType extends NativeModule {
+ startCollecting(): void;
+ stopCollecting(): FrameMetricsResult;
+}
+
+const mod = requireNativeModule('FrameMetrics');
+
+export const isAndroid = Platform.OS === 'android';
+
+export function startCollecting(): void {
+ mod.startCollecting();
+}
+
+export function stopCollecting(): FrameMetricsResult {
+ return mod.stopCollecting();
+}
+
+export type { FrameMetricsResult };
diff --git a/example/package.json b/example/package.json
index 53e7b2d..d21ccc9 100644
--- a/example/package.json
+++ b/example/package.json
@@ -48,5 +48,13 @@
},
"resolutions": {
"@react-native/gradle-plugin": "0.83.0"
+ },
+ "reanimated": {
+ "staticFeatureFlags": {
+ "ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS": true,
+ "IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS": true,
+ "USE_SYNCHRONIZABLE_FOR_MUTABLES": true,
+ "DISABLE_COMMIT_PAUSING_MECHANISM": true
+ }
}
}
diff --git a/example/src/demos/BenchmarkDemo.tsx b/example/src/demos/BenchmarkDemo.tsx
new file mode 100644
index 0000000..530cdb3
--- /dev/null
+++ b/example/src/demos/BenchmarkDemo.tsx
@@ -0,0 +1,596 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ Animated as RNAnimated,
+ Easing as RNEasing,
+ Pressable,
+} from 'react-native';
+import { EaseView } from 'react-native-ease';
+import Animated, {
+ createCSSAnimatedComponent,
+ css,
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from 'react-native-reanimated';
+import {
+ isAndroid,
+ startCollecting,
+ stopCollecting,
+ type FrameMetricsResult,
+} from '../../modules/frame-metrics/src';
+
+const ANIMATION_DISTANCE = 100;
+const ANIMATION_DURATION = 2000;
+const BENCHMARK_DURATION = 5000;
+const SETTLE_DELAY = 500;
+
+const BOX_SIZE = 20;
+const BOX_MARGIN = 2;
+
+// ---------------------------------------------------------------------------
+// Animation approaches
+// ---------------------------------------------------------------------------
+
+function EaseBox() {
+ return (
+
+ );
+}
+
+function EaseApproach({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function ReanimatedSVBox() {
+ const translateX = useSharedValue(0);
+
+ useEffect(() => {
+ translateX.value = withRepeat(
+ withTiming(ANIMATION_DISTANCE, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.linear,
+ }),
+ -1,
+ true,
+ );
+ }, [translateX]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: translateX.value }],
+ }));
+
+ return ;
+}
+
+function ReanimatedSVApproach({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+const CSSView = createCSSAnimatedComponent(View);
+
+const slideKeyframes = css.keyframes({
+ from: { transform: [{ translateX: 0 }] },
+ to: { transform: [{ translateX: ANIMATION_DISTANCE }] },
+});
+
+const cssStyles = css.create({
+ box: {
+ animationName: slideKeyframes,
+ animationDuration: `${ANIMATION_DURATION}ms`,
+ animationIterationCount: 'infinite',
+ animationDirection: 'alternate',
+ animationTimingFunction: 'linear',
+ },
+});
+
+function ReanimatedCSSBox() {
+ return ;
+}
+
+function ReanimatedCSSApproach({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function AnimatedBox() {
+ const translateX = useRef(new RNAnimated.Value(0)).current;
+
+ useEffect(() => {
+ const animation = RNAnimated.loop(
+ RNAnimated.sequence([
+ RNAnimated.timing(translateX, {
+ toValue: ANIMATION_DISTANCE,
+ duration: ANIMATION_DURATION,
+ easing: RNEasing.linear,
+ useNativeDriver: true,
+ }),
+ RNAnimated.timing(translateX, {
+ toValue: 0,
+ duration: ANIMATION_DURATION,
+ easing: RNEasing.linear,
+ useNativeDriver: true,
+ }),
+ ]),
+ );
+ animation.start();
+ return () => animation.stop();
+ }, [translateX]);
+
+ return (
+
+ );
+}
+
+function AnimatedApproach({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Approach registry
+// ---------------------------------------------------------------------------
+
+const APPROACHES = [
+ { key: 'ease', label: 'Ease', color: '#4a90d9', component: EaseApproach },
+ {
+ key: 'reanimated-sv',
+ label: 'Reanimated SV',
+ color: '#6c5ce7',
+ component: ReanimatedSVApproach,
+ },
+ {
+ key: 'reanimated-css',
+ label: 'Reanimated CSS',
+ color: '#a55eea',
+ component: ReanimatedCSSApproach,
+ },
+ {
+ key: 'animated',
+ label: 'RN Animated',
+ color: '#e17055',
+ component: AnimatedApproach,
+ },
+] as const;
+
+type ApproachKey = (typeof APPROACHES)[number]['key'];
+
+// ---------------------------------------------------------------------------
+// Benchmark component
+// ---------------------------------------------------------------------------
+
+export function BenchmarkDemo() {
+ const [viewCount, setViewCount] = useState(100);
+ const [activeApproach, setActiveApproach] = useState(
+ null,
+ );
+ const [results, setResults] = useState<
+ Partial>
+ >({});
+ const [running, setRunning] = useState(false);
+ const [statusText, setStatusText] = useState('');
+ const abortRef = useRef(false);
+
+ const runSingle = useCallback(
+ (key: ApproachKey): Promise => {
+ return new Promise((resolve) => {
+ setActiveApproach(key);
+
+ // Let views mount and settle
+ setTimeout(() => {
+ if (abortRef.current) {
+ resolve(null);
+ return;
+ }
+ startCollecting();
+
+ setTimeout(() => {
+ const result = stopCollecting();
+ setActiveApproach(null);
+ resolve(result);
+ }, BENCHMARK_DURATION);
+ }, SETTLE_DELAY);
+ });
+ },
+ [],
+ );
+
+ const runBenchmark = useCallback(
+ async (keys: ApproachKey[]) => {
+ abortRef.current = false;
+ setRunning(true);
+ setResults({});
+
+ for (const key of keys) {
+ if (abortRef.current) {
+ break;
+ }
+ setStatusText(
+ `Running: ${APPROACHES.find((a) => a.key === key)?.label}...`,
+ );
+ const result = await runSingle(key);
+ if (result) {
+ setResults((prev) => ({ ...prev, [key]: result }));
+ }
+ // Brief pause between approaches
+ await new Promise((r) => setTimeout(r, 300));
+ }
+
+ setStatusText('');
+ setRunning(false);
+ },
+ [runSingle],
+ );
+
+ const stopBenchmark = useCallback(() => {
+ abortRef.current = true;
+ try {
+ stopCollecting();
+ } catch {}
+ setActiveApproach(null);
+ setRunning(false);
+ setStatusText('');
+ }, []);
+
+ // Render the active approach
+ const ActiveComponent = activeApproach
+ ? APPROACHES.find((a) => a.key === activeApproach)?.component
+ : null;
+
+ return (
+
+ Animation Benchmark
+
+ Measures per-frame time with {viewCount} simultaneously animating views.
+ {'\n'}Lower is better.
+
+
+ {/* View count slider */}
+
+ Views: {viewCount}
+
+ {[10, 50, 100, 200, 500].map((n) => (
+ !running && setViewCount(n)}
+ >
+
+ {n}
+
+
+ ))}
+
+
+
+ {/* Run buttons */}
+
+ {running ? (
+
+ Stop
+
+ ) : (
+ <>
+ {APPROACHES.map((a) => (
+ runBenchmark([a.key])}
+ >
+ {a.label}
+
+ ))}
+ runBenchmark(APPROACHES.map((a) => a.key))}
+ >
+ Run All
+
+ >
+ )}
+
+
+ {/* Status / active animation area */}
+ {running && (
+
+ {statusText}
+ {activeApproach && ActiveComponent && (
+
+ )}
+
+ )}
+
+ {/* Results */}
+ {Object.keys(results).length > 0 && (
+
+ Results
+
+ {/* Primary table: main thread work per frame */}
+
+ {isAndroid
+ ? 'UI thread time per frame: anim + layout + draw (ms). Lower is better.'
+ : 'Display link callback time per frame (ms). Lower is better.'}
+
+
+
+ Approach
+
+ Avg
+ P95
+ P99
+
+ {APPROACHES.filter((a) => results[a.key]).map((a) => {
+ const r = results[a.key]!;
+ const avg = r.avgUiThreadTime;
+ const p95 = r.p95UiThreadTime;
+ const p99 = r.p99UiThreadTime;
+ return (
+
+
+ {a.label}
+
+
+ {avg.toFixed(2)}
+
+
+ {p95.toFixed(2)}
+
+
+ {p99.toFixed(2)}
+
+
+ );
+ })}
+
+ {/* Android-only: full breakdown */}
+ {isAndroid && (
+ <>
+
+ UI Thread Breakdown (avg ms)
+
+
+
+ Approach
+
+ Anim
+ Layout
+ Draw
+
+ {APPROACHES.filter((a) => results[a.key]).map((a) => {
+ const r = results[a.key]!;
+ return (
+
+
+ {a.label}
+
+
+ {(r.avgAnimationTime ?? 0).toFixed(2)}
+
+
+ {(r.avgLayoutTime ?? 0).toFixed(2)}
+
+
+ {(r.avgDrawTime ?? 0).toFixed(2)}
+
+
+ );
+ })}
+ >
+ )}
+
+ )}
+
+ );
+}
+
+function overheadColor(overheadMs: number): { color: string } {
+ if (overheadMs < 2) {
+ return { color: '#2ecc71' };
+ }
+ if (overheadMs < 5) {
+ return { color: '#f39c12' };
+ }
+ return { color: '#e74c3c' };
+}
+
+// ---------------------------------------------------------------------------
+// Styles
+// ---------------------------------------------------------------------------
+
+const styles = StyleSheet.create({
+ title: {
+ fontSize: 28,
+ fontWeight: '800',
+ color: '#fff',
+ marginBottom: 4,
+ },
+ subtitle: {
+ fontSize: 13,
+ color: '#8888aa',
+ marginBottom: 20,
+ lineHeight: 18,
+ },
+ sliderSection: {
+ marginBottom: 16,
+ },
+ sliderLabel: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#e0e0ff',
+ marginBottom: 8,
+ },
+ sliderRow: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ countButton: {
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ backgroundColor: '#2a2a4a',
+ borderRadius: 8,
+ },
+ countButtonActive: {
+ backgroundColor: '#4a90d9',
+ },
+ countButtonText: {
+ color: '#8888aa',
+ fontWeight: '600',
+ fontSize: 14,
+ },
+ countButtonTextActive: {
+ color: '#fff',
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ marginBottom: 16,
+ },
+ runButton: {
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: 8,
+ },
+ runAllButton: {
+ backgroundColor: '#2a2a4a',
+ },
+ stopButton: {
+ backgroundColor: '#d94a4a',
+ },
+ runButtonText: {
+ color: '#fff',
+ fontWeight: '700',
+ fontSize: 13,
+ },
+ animationArea: {
+ backgroundColor: '#16213e',
+ borderRadius: 16,
+ padding: 12,
+ marginBottom: 16,
+ maxHeight: 200,
+ overflow: 'hidden',
+ },
+ runningLabel: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: '#8888aa',
+ marginBottom: 8,
+ },
+ boxContainer: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+ box: {
+ width: BOX_SIZE,
+ height: BOX_SIZE,
+ backgroundColor: '#4a90d9',
+ borderRadius: 4,
+ margin: BOX_MARGIN,
+ },
+ resultsSection: {
+ backgroundColor: '#16213e',
+ borderRadius: 16,
+ padding: 16,
+ },
+ resultsSectionTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: '#e0e0ff',
+ marginBottom: 12,
+ },
+ subtitleText: {
+ fontSize: 13,
+ color: '#8888aa',
+ marginBottom: 12,
+ },
+ breakdownTitle: {
+ marginTop: 20,
+ },
+ tableHeader: {
+ flexDirection: 'row',
+ borderBottomWidth: 1,
+ borderBottomColor: '#2a2a4a',
+ paddingBottom: 8,
+ marginBottom: 4,
+ },
+ tableRow: {
+ flexDirection: 'row',
+ paddingVertical: 6,
+ },
+ tableCell: {
+ flex: 1,
+ fontSize: 13,
+ color: '#e0e0ff',
+ textAlign: 'center',
+ fontVariant: ['tabular-nums'],
+ },
+ tableCellLabel: {
+ flex: 1.5,
+ textAlign: 'left',
+ fontWeight: '600',
+ },
+});
diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts
index c97dd27..7a846ae 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -1,6 +1,7 @@
import type { ComponentType } from 'react';
import { BackgroundColorDemo } from './BackgroundColorDemo';
+import { BenchmarkDemo } from './BenchmarkDemo';
import { BannerDemo } from './BannerDemo';
import { BorderRadiusDemo } from './BorderRadiusDemo';
import { ButtonDemo } from './ButtonDemo';
@@ -78,6 +79,11 @@ export const demos: Record = {
title: 'Comparison',
section: 'Advanced',
},
+ 'benchmark': {
+ component: BenchmarkDemo,
+ title: 'Benchmark',
+ section: 'Advanced',
+ },
};
interface SectionData {