diff --git a/AGENTS.md b/AGENTS.md
index 6fda925..ddd5a7b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,6 +54,11 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran
7. Add tests and update README
8. Add an example/demo in the example app (`example/src/App.tsx` or a new screen)
+**Important:** When adding or changing props/features, also update:
+- `README.md` (props table + usage section)
+- `docs/docs/usage.mdx` (usage guide)
+- `docs/docs/api-reference.mdx` (API reference table)
+
## Development Commands
```sh
diff --git a/README.md b/README.md
index 8613109..856df58 100644
--- a/README.md
+++ b/README.md
@@ -426,6 +426,23 @@ By default, scale and rotation animate from the view's center. Use `transformOri
| `{ x: 0.5, y: 0.5 }` | Center (default) |
| `{ x: 1, y: 1 }` | Bottom-right |
+### Transform Perspective
+
+Control the 3D perspective depth for `rotateX` and `rotateY` animations. Lower values create a more dramatic 3D effect; higher values look flatter.
+
+```tsx
+
+```
+
+Default is `1280`, matching React Native's default perspective.
+
+> **iOS note:** On iOS, the parent view must not be flattened by Fabric for perspective to render correctly. Ensure the parent has `collapsable={false}` or a style that prevents flattening (e.g. `transform`, `opacity`, `zIndex`).
+
### Style Handling
`EaseView` accepts all standard `ViewStyle` properties. If a property appears in both `style` and `animate`, the animated value takes priority and the style value is stripped. A dev warning is logged when this happens.
@@ -465,6 +482,7 @@ A `View` that animates property changes using native platform APIs.
| `transition` | `Transition` | Animation configuration — a single config (timing, spring, or none) or a [per-property map](#per-property-transitions) |
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }` (center) |
+| `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). See [Transform Perspective](#transform-perspective). Default: `1280` |
| `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations. See [Hardware Layers](#hardware-layers-android). Default: `false` |
| `className` | `string` | NativeWind / Uniwind / Tailwind CSS class string. Requires NativeWind or Uniwind in your project. |
| `style` | `ViewStyle` | Non-animated styles (layout, colors, borders, etc.) |
diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt
index 5d3c7c5..7544fb3 100644
--- a/android/src/main/java/com/ease/EaseView.kt
+++ b/android/src/main/java/com/ease/EaseView.kt
@@ -17,6 +17,10 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.views.view.ReactViewGroup
import kotlin.math.sqrt
+// Matches React Native's camera distance normalization.
+// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L58
+private val CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER = sqrt(5.0).toFloat()
+
class EaseView(context: Context) : ReactViewGroup(context) {
// --- Previous animate values (for change detection) ---
@@ -194,9 +198,22 @@ class EaseView(context: Context) : ReactViewGroup(context) {
// --- Animated properties bitmask (set by ViewManager) ---
var animatedProperties: Int = 0
+ // --- Transform perspective (camera distance for 3D rotations) ---
+ var transformPerspective: Float = 1280f
+ set(value) {
+ field = value
+ applyCameraDistance(value)
+ }
+
+ private fun applyCameraDistance(perspective: Float) {
+ // Match React Native's conversion from CSS perspective to Android cameraDistance.
+ // https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L626-L637
+ val density = resources.displayMetrics.density
+ cameraDistance = density * density * perspective * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER
+ }
+
init {
- // Set camera distance for 3D perspective rotations (rotateX/rotateY)
- cameraDistance = resources.displayMetrics.density * 850f
+ applyCameraDistance(1280f)
// ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame.
outlineProvider = object : ViewOutlineProvider() {
@@ -345,6 +362,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
}
+
+ // Update backface visibility after setting initial rotation values.
+ // https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt#L967-L985
+ if (mask and (MASK_ROTATE or MASK_ROTATE_X or MASK_ROTATE_Y) != 0) {
+ setBackfaceVisibilityDependantOpacity()
+ }
} else if (allTransitionsNone()) {
// No transition (scalar) — set values immediately, cancel running animations
cancelAllAnimations()
@@ -613,12 +636,19 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}
}
+ // React Native's backfaceVisibility on Android checks rotationX/rotationY and sets alpha=0
+ // when the back face is showing. We must call setBackfaceVisibilityDependantOpacity() during
+ // rotation animations so the check runs each frame.
+ // https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt#L967-L985
+ private val isRotationProperty = setOf("rotation", "rotationX", "rotationY")
+
private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, config: TransitionConfig, loop: Boolean = false) {
cancelSpringForProperty(propertyName)
runningAnimators[propertyName]?.cancel()
val batchId = animationBatchId
pendingBatchAnimationCount++
+ val needsBackfaceUpdate = propertyName in isRotationProperty
val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
duration = config.duration.toLong()
@@ -636,6 +666,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
ObjectAnimator.RESTART
}
}
+ if (needsBackfaceUpdate) {
+ addUpdateListener { this@EaseView.setBackfaceVisibilityDependantOpacity() }
+ }
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationStart(animation: Animator) {
@@ -675,6 +708,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
val batchId = animationBatchId
pendingBatchAnimationCount++
+ val needsBackfaceUpdate = viewProperty == DynamicAnimation.ROTATION ||
+ viewProperty == DynamicAnimation.ROTATION_X ||
+ viewProperty == DynamicAnimation.ROTATION_Y
+
val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
.coerceAtLeast(0.01f)
@@ -688,6 +725,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (activeAnimationCount == 0) {
this@EaseView.onEaseAnimationStart()
}
+ if (needsBackfaceUpdate) {
+ this@EaseView.setBackfaceVisibilityDependantOpacity()
+ }
}
addEndListener { _, canceled, _, _ ->
this@EaseView.onEaseAnimationEnd()
@@ -809,6 +849,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
setAnimateBorderRadius(0f)
applyBackgroundColor(Color.TRANSPARENT)
+ transformPerspective = 1280f
isFirstMount = true
transitionConfigs = emptyMap()
}
diff --git a/android/src/main/java/com/ease/EaseViewManager.kt b/android/src/main/java/com/ease/EaseViewManager.kt
index 4282310..774f558 100644
--- a/android/src/main/java/com/ease/EaseViewManager.kt
+++ b/android/src/main/java/com/ease/EaseViewManager.kt
@@ -172,6 +172,13 @@ class EaseViewManager : ReactViewManager() {
view.transformOriginY = value
}
+ // --- Transform perspective ---
+
+ @ReactProp(name = "transformPerspective", defaultFloat = 1280f)
+ fun setTransformPerspective(view: EaseView, value: Float) {
+ view.transformPerspective = value
+ }
+
// --- Lifecycle ---
override fun onAfterUpdateTransaction(view: ReactViewGroup) {
diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx
index 1f8501f..8b9e5a4 100644
--- a/docs/docs/api-reference.mdx
+++ b/docs/docs/api-reference.mdx
@@ -14,6 +14,7 @@ A `View` that animates property changes using native platform APIs.
| `transition` | `Transition` | Single config or per-property map |
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions |
+| `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). Default: `1280` |
| `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations |
| `className` | `string` | NativeWind / Uniwind / Tailwind CSS class string |
| `style` | `ViewStyle` | Non-animated styles |
diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx
index 3628823..2b1ceda 100644
--- a/docs/docs/usage.mdx
+++ b/docs/docs/usage.mdx
@@ -205,6 +205,25 @@ Animations are interruptible by default. Changing `animate` values mid-animation
| `{ x: 0.5, y: 0.5 }` | Center (default) |
| `{ x: 1, y: 1 }` | Bottom-right |
+## Transform perspective
+
+Control the 3D perspective depth for `rotateX` and `rotateY` animations. Lower values create a more dramatic 3D effect; higher values look flatter.
+
+```tsx
+
+```
+
+Default is `1280`, matching React Native's default perspective.
+
+:::note[iOS]
+On iOS, the parent view must not be flattened by Fabric for perspective to render correctly. Ensure the parent has `collapsable={false}` or a style that prevents flattening (e.g. `transform`, `opacity`, `zIndex`).
+:::
+
## Style handling
If a property appears in both `style` and `animate`, the animated value takes priority and the style value is stripped.
diff --git a/example/src/demos/CardFlipDemo.tsx b/example/src/demos/CardFlipDemo.tsx
new file mode 100644
index 0000000..69ea75a
--- /dev/null
+++ b/example/src/demos/CardFlipDemo.tsx
@@ -0,0 +1,76 @@
+import { useState } from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import { EaseView } from 'react-native-ease';
+
+import { Section } from '../components/Section';
+import { Button } from '../components/Button';
+
+export function CardFlipDemo() {
+ const [flipped, setFlipped] = useState(false);
+ return (
+
+
+ {/* Front face */}
+
+ {' '}
+ Front
+ Tap to flip
+
+ {/* Back face — starts at -180 so backface is hidden, flips to 0 */}
+
+ {' '}
+ Back
+ 3D perspective flip
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ width: 200,
+ height: 260,
+ },
+ card: {
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+ borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backfaceVisibility: 'hidden',
+ boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
+ },
+ front: {
+ backgroundColor: '#4a90d9',
+ },
+ back: {
+ backgroundColor: '#d94a90',
+ },
+ emoji: {
+ fontSize: 48,
+ marginBottom: 12,
+ },
+ title: {
+ color: '#fff',
+ fontSize: 22,
+ fontWeight: '700',
+ },
+ subtitle: {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: 13,
+ marginTop: 4,
+ },
+});
diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts
index 4d518a2..208009d 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -4,6 +4,7 @@ import { Platform } from 'react-native';
import { BackgroundColorDemo } from './BackgroundColorDemo';
import { BenchmarkDemo } from './BenchmarkDemo';
import { BannerDemo } from './BannerDemo';
+import { CardFlipDemo } from './CardFlipDemo';
import { BorderRadiusDemo } from './BorderRadiusDemo';
import { ButtonDemo } from './ButtonDemo';
import { CombinedDemo } from './CombinedDemo';
@@ -39,6 +40,11 @@ export const demos: Record = {
'exit': { component: ExitDemo, title: 'Exit', section: 'Basic' },
'rotate': { component: RotateDemo, title: 'Rotate', section: 'Transform' },
'scale': { component: ScaleDemo, title: 'Scale', section: 'Transform' },
+ 'card-flip': {
+ component: CardFlipDemo,
+ title: 'Card Flip',
+ section: 'Transform',
+ },
'transform-origin': {
component: TransformOriginDemo,
title: 'Transform Origin',
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index fb93b39..7cb72c7 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -35,13 +35,14 @@ static inline CGFloat degreesToRadians(CGFloat degrees) {
// Compose a full CATransform3D from individual animate values.
// Order: Scale → RotateY → RotateX → RotateZ → Translate.
-// Perspective (m34) is always included — invisible when no 3D rotation.
+// Default perspective (1280) matches React Native's default.
+// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L624
static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY,
CGFloat translateX, CGFloat translateY,
CGFloat rotateZ, CGFloat rotateX,
- CGFloat rotateY) {
+ CGFloat rotateY, CGFloat perspective) {
CATransform3D t = CATransform3DIdentity;
- t.m34 = -1.0 / 850.0;
+ t.m34 = -1.0 / perspective;
t = CATransform3DTranslate(t, translateX, translateY, 0);
t = CATransform3DRotate(t, rotateZ, 0, 0, 1);
t = CATransform3DRotate(t, rotateX, 1, 0, 0);
@@ -159,6 +160,7 @@ @implementation EaseView {
BOOL _anyInterrupted;
CGFloat _transformOriginX;
CGFloat _transformOriginY;
+ CGFloat _transformPerspective;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -170,6 +172,7 @@ - (instancetype)initWithFrame:(CGRect)frame {
static const auto defaultProps = std::make_shared();
_props = defaultProps;
_isFirstMount = YES;
+ _transformPerspective = 1280.0;
_hasPendingFirstMountUpdate = NO;
_transformOriginX = 0.5;
_transformOriginY = 0.5;
@@ -294,17 +297,18 @@ - (CATransform3D)targetTransformFromProps:(const EaseViewProps &)p {
return composeTransform(
p.animateScaleX, p.animateScaleY, p.animateTranslateX,
p.animateTranslateY, degreesToRadians(p.animateRotate),
- degreesToRadians(p.animateRotateX), degreesToRadians(p.animateRotateY));
+ degreesToRadians(p.animateRotateX), degreesToRadians(p.animateRotateY),
+ _transformPerspective);
}
/// Compose a CATransform3D from EaseViewProps initial values.
- (CATransform3D)initialTransformFromProps:(const EaseViewProps &)p {
- return composeTransform(p.initialAnimateScaleX, p.initialAnimateScaleY,
- p.initialAnimateTranslateX,
- p.initialAnimateTranslateY,
- degreesToRadians(p.initialAnimateRotate),
- degreesToRadians(p.initialAnimateRotateX),
- degreesToRadians(p.initialAnimateRotateY));
+ return composeTransform(
+ p.initialAnimateScaleX, p.initialAnimateScaleY,
+ p.initialAnimateTranslateX, p.initialAnimateTranslateY,
+ degreesToRadians(p.initialAnimateRotate),
+ degreesToRadians(p.initialAnimateRotateX),
+ degreesToRadians(p.initialAnimateRotateY), _transformPerspective);
}
- (void)beginAnimationBatch {
@@ -372,7 +376,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps {
if (mask & kMaskOpacity)
self.layer.opacity = viewProps.initialAnimateOpacity;
if (hasTransform)
- self.layer.transform = initialT;
+ self.layer.transform = [self initialTransformFromProps:viewProps];
if (mask & kMaskBorderRadius) {
self.layer.cornerRadius = viewProps.initialAnimateBorderRadius;
self.layer.masksToBounds = viewProps.initialAnimateBorderRadius > 0 ||
@@ -402,7 +406,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps {
lowestTransformPropertyName(changedInitTransform);
EaseTransitionConfig transformConfig =
transitionConfigForProperty(transformName, viewProps);
- self.layer.transform = targetT;
+ self.layer.transform = [self targetTransformFromProps:viewProps];
if (transformConfig.type != "none") {
// Animate each changed sub-property individually using key paths.
// This avoids matrix interpolation which fails for cases like
@@ -518,7 +522,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps {
if (mask & kMaskOpacity)
self.layer.opacity = viewProps.animateOpacity;
if (hasTransform)
- self.layer.transform = targetT;
+ self.layer.transform = [self targetTransformFromProps:viewProps];
if (mask & kMaskBorderRadius) {
self.layer.cornerRadius = viewProps.animateBorderRadius;
self.layer.masksToBounds = viewProps.animateBorderRadius > 0;
@@ -569,6 +573,10 @@ - (void)updateProps:(const Props::Shared &)props
[self updateAnchorPoint];
}
+ if (_transformPerspective != newViewProps.transformPerspective) {
+ _transformPerspective = newViewProps.transformPerspective;
+ }
+
// Bitmask: which properties are animated. Non-animated = let style handle.
int mask = newViewProps.animatedProperties;
BOOL hasTransform = (mask & kMaskAnyTransform) != 0;
@@ -673,15 +681,99 @@ - (void)updateProps:(const Props::Shared &)props
self.layer.transform = [self targetTransformFromProps:newViewProps];
[self.layer removeAnimationForKey:kAnimKeyTransform];
} else {
- CATransform3D fromT = [self presentationTransform];
- CATransform3D toT = [self targetTransformFromProps:newViewProps];
- self.layer.transform = toT;
- [self applyAnimationForKeyPath:@"transform"
- animationKey:kAnimKeyTransform
- fromValue:[NSValue valueWithCATransform3D:fromT]
- toValue:[NSValue valueWithCATransform3D:toT]
- config:transformConfig
- loop:NO];
+ // Read "from" values from the presentation layer BEFORE setting
+ // the new model transform. During an active animation, CA tracks
+ // key-path values correctly. After completion, the model matrix
+ // with m34 can't be reliably decomposed, so fall back to old props.
+ BOOL isAnimating =
+ [self.layer animationForKey:kAnimKeyTransformRotateY] != nil ||
+ [self.layer animationForKey:kAnimKeyTransformRotateX] != nil ||
+ [self.layer animationForKey:kAnimKeyTransformRotateZ] != nil ||
+ [self.layer animationForKey:kAnimKeyTransformTransX] != nil ||
+ [self.layer animationForKey:kAnimKeyTransformTransY] != nil ||
+ [self.layer animationForKey:kAnimKeyTransformScaleX] != nil ||
+ [self.layer animationForKey:kAnimKeyTransformScaleY] != nil;
+ CGFloat fromTX, fromTY, fromSX, fromSY, fromR, fromRX, fromRY;
+ if (isAnimating) {
+ CALayer *pl = self.layer.presentationLayer ?: self.layer;
+ fromTX =
+ [[pl valueForKeyPath:@"transform.translation.x"] floatValue];
+ fromTY =
+ [[pl valueForKeyPath:@"transform.translation.y"] floatValue];
+ fromSX = [[pl valueForKeyPath:@"transform.scale.x"] floatValue];
+ fromSY = [[pl valueForKeyPath:@"transform.scale.y"] floatValue];
+ fromR = [[pl valueForKeyPath:@"transform.rotation"] floatValue];
+ fromRX = [[pl valueForKeyPath:@"transform.rotation.x"] floatValue];
+ fromRY = [[pl valueForKeyPath:@"transform.rotation.y"] floatValue];
+ } else {
+ fromTX = oldViewProps.animateTranslateX;
+ fromTY = oldViewProps.animateTranslateY;
+ fromSX = oldViewProps.animateScaleX;
+ fromSY = oldViewProps.animateScaleY;
+ fromR = degreesToRadians(oldViewProps.animateRotate);
+ fromRX = degreesToRadians(oldViewProps.animateRotateX);
+ fromRY = degreesToRadians(oldViewProps.animateRotateY);
+ }
+ self.layer.transform = [self targetTransformFromProps:newViewProps];
+ if (changedTransformMask & kMaskTranslateX) {
+ [self applyAnimationForKeyPath:@"transform.translation.x"
+ animationKey:kAnimKeyTransformTransX
+ fromValue:@(fromTX)
+ toValue:@(newViewProps.animateTranslateX)
+ config:transformConfig
+ loop:NO];
+ }
+ if (changedTransformMask & kMaskTranslateY) {
+ [self applyAnimationForKeyPath:@"transform.translation.y"
+ animationKey:kAnimKeyTransformTransY
+ fromValue:@(fromTY)
+ toValue:@(newViewProps.animateTranslateY)
+ config:transformConfig
+ loop:NO];
+ }
+ if (changedTransformMask & kMaskScaleX) {
+ [self applyAnimationForKeyPath:@"transform.scale.x"
+ animationKey:kAnimKeyTransformScaleX
+ fromValue:@(fromSX)
+ toValue:@(newViewProps.animateScaleX)
+ config:transformConfig
+ loop:NO];
+ }
+ if (changedTransformMask & kMaskScaleY) {
+ [self applyAnimationForKeyPath:@"transform.scale.y"
+ animationKey:kAnimKeyTransformScaleY
+ fromValue:@(fromSY)
+ toValue:@(newViewProps.animateScaleY)
+ config:transformConfig
+ loop:NO];
+ }
+ if (changedTransformMask & kMaskRotate) {
+ [self applyAnimationForKeyPath:@"transform.rotation.z"
+ animationKey:kAnimKeyTransformRotateZ
+ fromValue:@(fromR)
+ toValue:@(degreesToRadians(
+ newViewProps.animateRotate))
+ config:transformConfig
+ loop:NO];
+ }
+ if (changedTransformMask & kMaskRotateX) {
+ [self applyAnimationForKeyPath:@"transform.rotation.x"
+ animationKey:kAnimKeyTransformRotateX
+ fromValue:@(fromRX)
+ toValue:@(degreesToRadians(
+ newViewProps.animateRotateX))
+ config:transformConfig
+ loop:NO];
+ }
+ if (changedTransformMask & kMaskRotateY) {
+ [self applyAnimationForKeyPath:@"transform.rotation.y"
+ animationKey:kAnimKeyTransformRotateY
+ fromValue:@(fromRY)
+ toValue:@(degreesToRadians(
+ newViewProps.animateRotateY))
+ config:transformConfig
+ loop:NO];
+ }
}
}
}
@@ -818,6 +910,7 @@ - (void)prepareForRecycle {
_anyInterrupted = NO;
_transformOriginX = 0.5;
_transformOriginY = 0.5;
+ _transformPerspective = 1280.0;
self.layer.anchorPoint = CGPointMake(0.5, 0.5);
self.layer.opacity = 1.0;
self.layer.transform = CATransform3DIdentity;
diff --git a/src/EaseView.tsx b/src/EaseView.tsx
index ab63651..3525f19 100644
--- a/src/EaseView.tsx
+++ b/src/EaseView.tsx
@@ -7,6 +7,7 @@ import type {
Transition,
TransitionEndEvent,
TransformOrigin,
+ TransformPerspective,
} from './types';
/** Identity values used as defaults for animate/initialAnimate. */
@@ -196,6 +197,18 @@ export type EaseViewProps = ViewProps & {
useHardwareLayer?: boolean;
/** Pivot point for scale and rotation as 0–1 fractions. @default { x: 0.5, y: 0.5 } (center) */
transformOrigin?: TransformOrigin;
+ /**
+ * Distance of the camera from the z=0 plane for 3D transforms (rotateX,
+ * rotateY). Higher values produce a flatter look; lower values exaggerate
+ * perspective.
+ *
+ * **iOS note:** On iOS, the parent view must not be flattened by Fabric for
+ * perspective to render correctly. Ensure the parent has `collapsable={false}`
+ * or a style that prevents flattening (e.g. `transform`, `opacity`, `zIndex`).
+ *
+ * @default 1280
+ */
+ transformPerspective?: TransformPerspective;
/** NativeWind / Uniwind / Tailwind CSS class string. Requires a compatible className interop in your project. */
className?: string;
};
@@ -207,6 +220,7 @@ export function EaseView({
onTransitionEnd,
useHardwareLayer = false,
transformOrigin,
+ transformPerspective,
style,
...rest
}: EaseViewProps) {
@@ -328,6 +342,7 @@ export function EaseView({
useHardwareLayer={useHardwareLayer}
transformOriginX={transformOrigin?.x ?? 0.5}
transformOriginY={transformOrigin?.y ?? 0.5}
+ transformPerspective={transformPerspective ?? 1280}
{...rest}
/>
);
diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx
index 1592df9..eaaf54e 100644
--- a/src/EaseView.web.tsx
+++ b/src/EaseView.web.tsx
@@ -7,6 +7,7 @@ import type {
Transition,
TransitionEndEvent,
TransformOrigin,
+ TransformPerspective,
} from './types';
/** Identity values used as defaults for animate/initialAnimate. */
@@ -112,6 +113,12 @@ export type EaseViewProps = {
/** No-op on web. */
useHardwareLayer?: boolean;
transformOrigin?: TransformOrigin;
+ /**
+ * Distance of the camera from the z=0 plane for 3D transforms (rotateX, rotateY).
+ * Higher values produce a flatter, more telephoto look; lower values exaggerate
+ * perspective. @default 1280
+ */
+ transformPerspective?: TransformPerspective;
style?: StyleProp;
children?: React.ReactNode;
};
@@ -132,8 +139,14 @@ function resolveAnimateValues(props: AnimateProps | undefined): Required<
};
}
-function buildTransform(vals: ReturnType): string {
+function buildTransform(
+ vals: ReturnType,
+ perspective: number | false,
+): string {
const parts: string[] = [];
+ if (perspective !== false) {
+ parts.push(`perspective(${perspective}px)`);
+ }
if (vals.translateX !== 0 || vals.translateY !== 0) {
parts.push(`translate(${vals.translateX}px, ${vals.translateY}px)`);
}
@@ -269,10 +282,18 @@ export function EaseView({
onTransitionEnd,
useHardwareLayer: _useHardwareLayer,
transformOrigin,
+ transformPerspective = 1280,
style,
children,
}: EaseViewProps) {
const resolved = resolveAnimateValues(animate);
+
+ const uses3D =
+ animate?.rotateX != null ||
+ animate?.rotateY != null ||
+ initialAnimate?.rotateX != null ||
+ initialAnimate?.rotateY != null;
+
const hasInitial = initialAnimate != null;
const [mounted, setMounted] = useState(!hasInitial);
// On web, View ref gives us the underlying DOM element.
@@ -370,8 +391,14 @@ export function EaseView({
: resolveAnimateValues(undefined);
const toValues = resolveAnimateValues(animate);
- const fromTransform = buildTransform(fromValues);
- const toTransform = buildTransform(toValues);
+ const fromTransform = buildTransform(
+ fromValues,
+ uses3D && transformPerspective,
+ );
+ const toTransform = buildTransform(
+ toValues,
+ uses3D && transformPerspective,
+ );
const name = `ease-loop-${++keyframeCounter}`;
animationNameRef.current = name;
@@ -421,13 +448,23 @@ export function EaseView({
el.style.animation = '';
animationNameRef.current = null;
};
- }, [loopMode, animate, initialAnimate, loopDuration, loopEasing, getElement]);
+ }, [
+ loopMode,
+ animate,
+ initialAnimate,
+ loopDuration,
+ loopEasing,
+ getElement,
+ uses3D,
+ transformPerspective,
+ ]);
// Build animated style using RN transform array format.
// react-native-web converts these to CSS transform strings.
const animatedStyle: ViewStyle = {
opacity: displayValues.opacity,
transform: [
+ ...(uses3D ? [{ perspective: transformPerspective }] : []),
...(displayValues.translateX !== 0
? [{ translateX: displayValues.translateX }]
: []),
@@ -439,12 +476,8 @@ export function EaseView({
...(displayValues.rotate !== 0
? [{ rotate: `${displayValues.rotate}deg` }]
: []),
- ...(displayValues.rotateX !== 0
- ? [{ rotateX: `${displayValues.rotateX}deg` }]
- : []),
- ...(displayValues.rotateY !== 0
- ? [{ rotateY: `${displayValues.rotateY}deg` }]
- : []),
+ ...(uses3D ? [{ rotateX: `${displayValues.rotateX}deg` }] : []),
+ ...(uses3D ? [{ rotateY: `${displayValues.rotateY}deg` }] : []),
],
...(displayValues.borderRadius > 0
? { borderRadius: displayValues.borderRadius }
diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts
index 48aad0e..aca7703 100644
--- a/src/EaseViewNativeComponent.ts
+++ b/src/EaseViewNativeComponent.ts
@@ -66,6 +66,9 @@ export interface NativeProps extends ViewProps {
transformOriginX?: CodegenTypes.WithDefault;
transformOriginY?: CodegenTypes.WithDefault;
+ // 3D perspective distance (default 1280, matches RN default)
+ transformPerspective?: CodegenTypes.WithDefault;
+
// Events
onTransitionEnd?: CodegenTypes.DirectEventHandler<
Readonly<{ finished: boolean }>
diff --git a/src/index.tsx b/src/index.tsx
index 0f260e2..612c5da 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -12,4 +12,5 @@ export type {
EasingType,
TransitionEndEvent,
TransformOrigin,
+ TransformPerspective,
} from './types';
diff --git a/src/types.ts b/src/types.ts
index 312f727..4b9f916 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -79,6 +79,17 @@ export type TransformOrigin = {
y?: number;
};
+/**
+ * Distance of the camera from the z=0 plane for 3D transforms (rotateX, rotateY).
+ * Higher values produce a flatter, more telephoto look; lower values exaggerate
+ * perspective. @default 1280 (matches React Native default)
+ *
+ * **iOS note:** On iOS, the parent view must not be flattened by Fabric for
+ * perspective to render correctly. Ensure the parent has `collapsable={false}`
+ * or a style that prevents flattening (e.g. `transform`, `opacity`, `zIndex`).
+ */
+export type TransformPerspective = number;
+
/** Animatable view properties. Unspecified properties default to their identity values. */
export type AnimateProps = {
/** View opacity (0–1). @default 1 */