Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

`react-native-ease` is a React Native library that provides declarative, native-powered animations via a single `EaseView` component. It uses Core Animation (iOS) and ObjectAnimator/SpringAnimation (Android) — no JS animation loop, no worklets, no C++ runtime.
`react-native-ease` is a React Native library that provides declarative, native-powered animations via `EaseView` and `EaseText` components. It uses Core Animation (iOS) and ObjectAnimator/SpringAnimation (Android) — no JS animation loop, no worklets, no C++ runtime.

**Fabric (new architecture) only.** Does not support the old architecture.

Expand All @@ -11,6 +11,8 @@
```
src/ # TypeScript source (library)
EaseView.tsx # React component — flattens props to native
EaseText.tsx # Text component — EaseView wrapper + Text with color interpolation
useColorTransition.ts # JS-side color interpolation hook (requestAnimationFrame)
EaseViewNativeComponent.ts # Codegen spec — defines native props/events
types.ts # Public TypeScript types
index.tsx # Public exports
Expand All @@ -26,8 +28,7 @@ android/src/main/java/com/ease/
EasePackage.kt # Package registration

example/ # Demo app (separate workspace)
src/App.tsx # Main demo screen with animation examples
src/ComparisonScreen.tsx # Comparison with Reanimated
src/demos/ # Demo screens (one per feature)
src/components/ # Shared demo components (Section, Button, TabBar)
```

Expand All @@ -43,6 +44,24 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran

**Key design pattern:** All animation logic lives on the native side. The JS layer is purely a prop resolver — no animation state, no timers, no refs.

## EaseText Architecture

`EaseText` is a **JS-only component** — no native code. It composes `EaseView` (for native transforms/opacity) with a standard `<Text>` (for text rendering).

```
<EaseText interpolateColor="#000" animate={{ translateY, scale }} />
→ <EaseView animate={{ translateY, scale }}> ← native animations
<Text style={{ color: interpolated }}> ← JS color interpolation
```

**Color:** Two modes:
- `style.color` — instant change, zero JS cost (same as `<Text>`)
- `interpolateColor` prop — smooth JS interpolation via `requestAnimationFrame`, follows the `color` key in `transition` (or falls back to `default`)

**Why not native text color animation:** Fabric's text rendering pipeline (`RCTParagraphComponentView` on iOS) manages text via `NSAttributedString` in the shadow tree. The `attributedText` is readonly — color can't be mutated from outside the Fabric commit cycle. Android's `ReactTextView.setTextColor()` works, but iOS doesn't. JS interpolation keeps behavior consistent cross-platform.

**TextAnimateProps:** `Omit<AnimateProps, 'borderRadius' | 'backgroundColor' | 'borderWidth' | 'borderColor' | 'shadowOpacity' | 'shadowRadius' | 'shadowColor' | 'shadowOffset' | 'elevation'>` — transform/opacity props only; view-only properties (border, background, shadow, elevation) are excluded. Color is handled separately via `interpolateColor`.

## Adding a New Animatable Property

1. Add to `AnimateProps` in `src/types.ts`
Expand Down Expand Up @@ -117,3 +136,5 @@ Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, etc.
- **Spring damping ratio on Android is derived** from `damping`, `stiffness`, and `mass` using: `dampingRatio = damping / (2 * sqrt(stiffness * mass))`. iOS passes these values directly to `CASpringAnimation`.
- **Animation batching:** Both platforms track animation batches with a generation ID. When new animations start, any pending old-batch callbacks are fired as interrupted (`finished: false`).
- **Loop only works with timing animations**, not springs. Loop requires `initialAnimate` to define the start value.
- **EaseText color has two modes:** `style.color` for instant changes (zero cost), `interpolateColor` prop for smooth JS interpolation via requestAnimationFrame. Transforms/opacity are always native via EaseView.
- **EaseText wraps EaseView + Text.** Layout behaves like a View containing a Text, not a bare Text. Use a wrapper View with `position: 'absolute'` for floating label patterns (see FloatingLabelDemo).
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,49 @@ Default is `1280`, matching React Native's default perspective.
/>
```

### Animated Text (`EaseText`)

`EaseText` provides native transform/opacity animations on text plus optional smooth color transitions. It composes `EaseView` (for native animations) with a standard `<Text>` (for text rendering).

```tsx
import { EaseText } from 'react-native-ease';

// Smooth color + native transforms
<EaseText
interpolateColor={focused ? '#000' : '#999'}
animate={{ translateY: focused ? -12 : 0, scale: focused ? 0.8 : 1 }}
transition={{
color: { type: 'timing', duration: 150 },
transform: { type: 'spring', damping: 12, stiffness: 250 },
}}
transformOrigin={{ x: 0, y: 0.5 }}
style={{ fontSize: 15 }}
numberOfLines={1}
>
{label}
</EaseText>

// Instant color via style (zero JS cost)
<EaseText
animate={{ scale: pressed ? 0.95 : 1 }}
transition={{ type: 'spring', damping: 15, stiffness: 200 }}
style={{ color: pressed ? '#000' : '#999', fontSize: 15 }}
>
Tap me
</EaseText>
```

**Two color modes:**

- `style.color` — instant change, zero JS cost. Recommended when you don't need smooth color transitions.
- `interpolateColor` prop — smooth JS interpolation via `requestAnimationFrame`. Follows the `color` key in `transition` (or `default` as fallback).

Transforms and opacity are always native (60fps via the internal `EaseView`). Standard `TextProps` like `numberOfLines`, `ellipsizeMode`, `selectable`, and `onPress` are passed through to the underlying `<Text>`.

**Layout note:** `EaseText` renders a wrapping `View` around `<Text>`. For floating-label patterns, place it in an absolutely positioned wrapper.

**Spring color caveat:** spring transitions on `interpolateColor` are approximated as a 500 ms `easeOut` (real spring physics on the JS thread isn't worth the cost). Prefer `timing` for color transitions when precise feel matters.

## API Reference

### `<EaseView>`
Expand Down Expand Up @@ -602,6 +645,29 @@ A per-property map that applies different transition configs to different proper
| `backgroundColor` | backgroundColor |
| `border` | borderWidth, borderColor |
| `shadow` | shadowOpacity, shadowRadius, shadowColor, shadowOffset, elevation |
| `color` | text color (EaseText only, JS-interpolated) |

### `<EaseText>`

A `Text` that animates transforms/opacity natively (via the internal `EaseView`) and optionally interpolates `color` on the JS thread. Accepts all standard `TextProps`.

| Prop | Type | Description |
| ------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `animate` | `TextAnimateProps` | Target values for animated transforms/opacity. Excludes view-only props (border, shadow, background). |
| `initialAnimate` | `TextAnimateProps` | Starting values for enter animations. |
| `transition` | `Transition` | Same as `EaseView` — supports an extra `color` key in `TransitionMap` for the JS color interpolation. |
| `interpolateColor` | `ColorValue` | Target color, smoothly interpolated on the JS thread via `requestAnimationFrame`. |
| `initialInterpolateColor` | `ColorValue` | Starting color for mount animations (only applied when `interpolateColor` is set). |
| `onTransitionEnd` | `(event) => void` | Called when transform/opacity animations complete (does not fire for color interpolation). |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }`. |
| `transformPerspective` | `number` | Camera distance for 3D rotations. Default: `1280`. |
| `useHardwareLayer` | `boolean` | Android only — same as `EaseView`. |
| `style` | `TextStyle` | Use `style.color` for instant color changes (zero JS cost). |
| ...rest | `TextProps` | All standard `Text` props (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, etc.). |

### `TextAnimateProps`

`Omit<AnimateProps, 'borderRadius' \| 'backgroundColor' \| 'borderWidth' \| 'borderColor' \| 'shadowOpacity' \| 'shadowRadius' \| 'shadowColor' \| 'shadowOffset' \| 'elevation'>` — same transform and opacity props as `AnimateProps`. View-only properties are excluded; color is set via `interpolateColor` or `style.color`.

## Hardware Layers (Android)

Expand Down
23 changes: 23 additions & 0 deletions docs/docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ A per-property map that applies different transition configs to different proper
| `backgroundColor` | backgroundColor |
| `border` | borderWidth, borderColor |
| `shadow` | shadowOpacity, shadowRadius, shadowColor, shadowOffset, elevation |
| `color` | text color (EaseText only, JS-interpolated) |

## `<EaseText>`

A `Text` that animates transforms/opacity natively (via the internal `EaseView`) and optionally interpolates `color` on the JS thread. Accepts all standard `TextProps`.

| Prop | Type | Description |
| ------------------------- | ---------------------------- | ----------- |
| `animate` | `TextAnimateProps` | Target values for animated transforms/opacity |
| `initialAnimate` | `TextAnimateProps` | Starting values for enter animations |
| `transition` | `Transition` | Single config or per-property map (supports `color` key for JS color interpolation) |
| `interpolateColor` | `ColorValue` | Target color, smoothly interpolated on the JS thread via `requestAnimationFrame` |
| `initialInterpolateColor` | `ColorValue` | Starting color for mount animations |
| `onTransitionEnd` | `(event) => void` | Fires when transform/opacity animations complete (does not fire for color interpolation) |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point as 0–1 fractions |
| `transformPerspective` | `number` | Camera distance for 3D rotations. Default: `1280` |
| `useHardwareLayer` | `boolean` | Android only |
| `style` | `TextStyle` | Use `style.color` for instant color changes (zero JS cost) |
| `...rest` | `TextProps` | All standard `Text` props (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, etc.) |

## `TextAnimateProps`

`Omit<AnimateProps, 'borderRadius' | 'backgroundColor' | 'borderWidth' | 'borderColor' | 'shadowOpacity' | 'shadowRadius' | 'shadowColor' | 'shadowOffset' | 'elevation'>` — same transform/opacity props as `AnimateProps`. View-only properties are excluded.

## Hardware layers (Android)

Expand Down
47 changes: 47 additions & 0 deletions docs/docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,50 @@ If a property appears in both `style` and `animate`, the animated value takes pr
<Text>Notification card</Text>
</EaseView>
```

## Animated text (`EaseText`)

`EaseText` provides native transform/opacity animations on text plus optional smooth color transitions. Transforms and opacity run natively (60fps); color is interpolated on the JS thread when `interpolateColor` is set.

```tsx
import { EaseText } from 'react-native-ease';

// Smooth color + native transforms
<EaseText
interpolateColor={focused ? '#000' : '#999'}
animate={{ translateY: focused ? -12 : 0, scale: focused ? 0.8 : 1 }}
transition={{
color: { type: 'timing', duration: 150 },
transform: { type: 'spring', damping: 12, stiffness: 250 },
}}
transformOrigin={{ x: 0, y: 0.5 }}
style={{ fontSize: 15 }}
numberOfLines={1}
>
{label}
</EaseText>

// Instant color via style (zero JS cost)
<EaseText
animate={{ scale: pressed ? 0.95 : 1 }}
transition={{ type: 'spring', damping: 15, stiffness: 200 }}
style={{ color: pressed ? '#000' : '#999', fontSize: 15 }}
>
Tap me
</EaseText>
```

Two color modes:

- `style.color` — instant change, zero JS cost. Recommended when you don't need smooth color transitions.
- `interpolateColor` prop — smooth interpolation via `requestAnimationFrame`. Follows the `color` key in `transition` (or `default` as fallback).

All standard `TextProps` (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, ...) are passed through to the underlying `<Text>`.

:::note[Layout]
`EaseText` renders a wrapping `View` around `<Text>` (it composes `EaseView` + `Text`). For floating-label patterns, place it in an absolutely positioned wrapper.
:::

:::note[Spring color]
Spring transitions on `interpolateColor` are approximated as a 500 ms `easeOut` (real spring physics on the JS thread isn't worth the cost). Prefer `timing` for color when feel matters.
:::
76 changes: 76 additions & 0 deletions example/src/demos/FloatingLabelDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { View, TextInput, StyleSheet } from 'react-native';
import { EaseText } from 'react-native-ease';

import { Section } from '../components/Section';

function FloatingInput({ label }: { label: string }) {
const [focused, setFocused] = useState(false);
const [value, setValue] = useState('');
const compact = focused || value.length > 0;

return (
<View style={styles.inputContainer}>
<View style={styles.labelWrapper}>
<EaseText
interpolateColor={compact ? '#e94560' : '#8892b0'}
animate={{
translateY: compact ? -24 : 0,
scale: compact ? 0.75 : 1,
}}
transition={{
color: { type: 'timing', duration: 150 },
transform: { type: 'spring', damping: 12, stiffness: 250 },
}}
transformOrigin={{ x: 0, y: 0.5 }}
style={styles.floatingLabel}
>
{label}
</EaseText>
</View>
<TextInput
style={styles.textInput}
value={value}
onChangeText={setValue}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholderTextColor="transparent"
/>
</View>
);
}

export function FloatingLabelDemo() {
return (
<Section title="Floating Label Input">
<FloatingInput label="Email" />
<FloatingInput label="Password" />
<FloatingInput label="Full Name" />
</Section>
);
}

const styles = StyleSheet.create({
inputContainer: {
position: 'relative',
marginBottom: 20,
borderBottomWidth: 1,
borderBottomColor: '#2a2a4a',
paddingTop: 22,
},
labelWrapper: {
position: 'absolute',
left: 0,
top: 22,
},
floatingLabel: {
fontSize: 15,
fontWeight: '400',
},
textInput: {
fontSize: 15,
color: '#fff',
paddingVertical: 8,
paddingHorizontal: 0,
},
});
83 changes: 83 additions & 0 deletions example/src/demos/TextColorDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { StyleSheet } from 'react-native';
import { EaseText } from 'react-native-ease';

import { Section } from '../components/Section';
import { Button } from '../components/Button';

export function TextColorDemo() {
const [focused, setFocused] = useState(false);

return (
<Section title="Text Color">
<EaseText
interpolateColor={focused ? '#e94560' : '#8892b0'}
transition={{ type: 'timing', duration: 300 }}
style={styles.label}
>
Smooth color transition (300ms)
</EaseText>

<EaseText
interpolateColor={focused ? '#e94560' : '#8892b0'}
animate={{ scale: focused ? 1.05 : 1 }}
transition={{ type: 'spring', damping: 15, stiffness: 120 }}
style={styles.heading}
>
Color + Scale
</EaseText>

<EaseText
interpolateColor={focused ? '#e94560' : '#8892b0'}
animate={{
opacity: focused ? 1 : 0.5,
translateX: focused ? 10 : 0,
}}
transition={{ type: 'timing', duration: 400 }}
style={styles.subtitle}
>
Color + Opacity + TranslateX
</EaseText>

<EaseText
style={[
styles.instant,
focused ? styles.instantActive : styles.instantIdle,
]}
>
Instant color (style.color, zero JS cost)
</EaseText>

<Button
label={focused ? 'Blur' : 'Focus'}
onPress={() => setFocused((v) => !v)}
/>
</Section>
);
}

const styles = StyleSheet.create({
label: {
fontSize: 16,
marginBottom: 16,
},
heading: {
fontSize: 28,
fontWeight: '700',
marginBottom: 16,
},
subtitle: {
fontSize: 14,
marginBottom: 16,
},
instant: {
fontSize: 14,
marginBottom: 24,
},
instantIdle: {
color: '#8892b0',
},
instantActive: {
color: '#e94560',
},
});
Loading
Loading