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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## Next

- Added: `createAnimatedRestyleComponent` helper and a guide explaining why animated Restyle components must nest the animated component inside Restyle (not the other way around) to stay compatible with `react-native-reanimated` 4.4+'s `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` flag [#TBD](https://github.com/Shopify/restyle/pull/TBD) by [tothvoj-gl](https://github.com/tothvoj-gl)

## 2.4.5 - 2025-03-19

- Fixed: dist folder not being generated when building the project [#302](https://github.com/Shopify/restyle/pull/302) by [kelset](https://github.com/naqvitalha)
Expand Down
118 changes: 118 additions & 0 deletions documentation/docs/guides/animating-restyle-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
id: animating-restyle-components
title: Animating Restyle components
---

Restyle doesn't depend on any animation library, but it's common to want to animate a
themed component — for example, animating a `Box`'s `borderRadius` or `backgroundColor`
between two theme values.

## Nest the animated component inside Restyle, not the other way around

Given a themed component, there are two ways to make it animatable:

```tsx
// A: animated outer, Restyle inner
const AnimatedBox = Animated.createAnimatedComponent(createBox<Theme>());
```

```tsx
// B: Restyle outer, animated inner
const AnimatedBox = createAnimatedRestyleComponent<Theme, typeof Animated.View>(
Animated.createAnimatedComponent(View),
);
```

These look interchangeable, but **only B is safe with `react-native-reanimated` 4.4+**.

Reanimated 4.4 introduced a default-on performance flag,
`FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS`. Once an animation settles, Reanimated
re-renders the component it wraps with the resolved style values spread on as
top-level props, in addition to `style`:

```
borderRadius: 13
backgroundColor: 'rgba(255,209,102,1)'
```

In ordering **A**, the component Reanimated wraps _is_ the Restyle component, so those
resolved values land as `borderRadius` / `backgroundColor` props on it — and Restyle
treats any theme-key prop as a token lookup. A raw number or color string is not a key
in your theme, so `getThemeValue` throws:

```
Value '13' does not exist in theme['borderRadii']
Value 'rgba(255,209,102,1)' does not exist in theme['colors']
```

In ordering **B**, Reanimated wraps a plain `View` that sits _inside_ Restyle's
`BaseComponent`. The settled props get spread onto that inner `View` — a component
Restyle's props never pass back through — so there's nothing to collide with. Restyle
only ever sees the props it was given in JSX (`style`, and whichever theme props you
passed explicitly).

## `createAnimatedRestyleComponent`

`createAnimatedRestyleComponent` is a small helper for ordering B: it's `createBox`
with its `Props` generic inferred from the animated component you hand it, so you get
that component's own prop types (its `style` prop in particular) without having to
redeclare them yourself.

```tsx
import {Animated, View} from 'react-native';
// or, for react-native-reanimated:
// import Animated from 'react-native-reanimated';
import {createAnimatedRestyleComponent, useTheme} from '@shopify/restyle';
import {Theme} from './theme';

const AnimatedBox = createAnimatedRestyleComponent<Theme, typeof Animated.View>(
Animated.createAnimatedComponent(View),
);

const Example = () => {
const {borderRadii} = useTheme<Theme>();
const progress = useSharedValue(0);

const animatedStyle = useAnimatedStyle(() => ({
borderRadius: interpolate(progress.value, [0, 1], [
borderRadii.sm,
borderRadii.lg,
]),
}));

return (
<AnimatedBox
backgroundColor="cardBackground"
width={120}
height={120}
style={animatedStyle}
/>
);
};
```

Restyle itself never imports an animation library — `createAnimatedRestyleComponent`
just takes whatever already-animated component you construct and wraps it, so this
works the same way with `react-native-reanimated`, the built-in `Animated` API, or
anything else that produces a component accepting a `style` prop.

The component `createAnimatedRestyleComponent` returns behaves exactly like `Box`:
same restyle functions, same ref forwarding, and it composes with
[variants](/fundamentals/variants) the same way any [custom component](/fundamentals/components/custom-components)
does — pass `createVariant(...)` alongside `boxRestyleFunctions` to
`createRestyleComponent` directly if you need a variant-aware animated component.

## If you're not using `createAnimatedRestyleComponent`

The same ordering rule applies if you build your own animated component instead —
with `createRestyleComponent` or `createBox` directly:

```tsx
const AnimatedBox = createBox<Theme, Animated.AnimateProps<ViewProps>>(
Animated.createAnimatedComponent(View),
);
```

The important part isn't the helper, it's the nesting order: give Restyle the
already-animated component as its `BaseComponent`, don't wrap a Restyle component in
`Animated.createAnimatedComponent`.
1 change: 1 addition & 0 deletions documentation/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ module.exports = {
collapsed: true,
items: [
'guides/dark-mode',
'guides/animating-restyle-components',
'guides/fixture-app',
'guides/shopify-design-system',
'guides/migrating-to-v2',
Expand Down
39 changes: 39 additions & 0 deletions src/createAnimatedRestyleComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';

import createBox from './createBox';
import {BaseTheme} from './types';

/**
* Wraps an already-animated component (e.g. `Animated.createAnimatedComponent(View)`
* from `react-native-reanimated`, or `Animated.View` from the built-in `Animated` API)
* with Restyle's `Box` props, inferring the wrapped component's own prop types (its
* `style` prop in particular) instead of requiring them to be re-declared by hand.
*
* Restyle never imports an animation library itself: the caller constructs the animated
* component and hands it in, so this stays a zero-dependency wrapper around `createBox`.
*
* Ordering matters: nest the animated component *inside* Restyle
* (`createAnimatedRestyleComponent(Animated.createAnimatedComponent(View))`), not the
* other way around (`Animated.createAnimatedComponent(createBox())`). Outside-in, a
* library that re-renders with resolved style values spread as top-level props (as
* react-native-reanimated 4.4+ does once an animation settles, when its
* `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` flag is on) lands those raw values in
* Restyle's theme-token prop namespace. Inside-out, those props are spread on the
* plain wrapped component instead, never reaching Restyle's prop parsing. See the
* "Animating Restyle components" guide for the full explanation and an example.
*/
const createAnimatedRestyleComponent = <
Theme extends BaseTheme,
AnimatedComponentType extends React.ComponentType<any>,
EnableShorthand extends boolean = true,
>(
AnimatedComponent: AnimatedComponentType,
) => {
return createBox<
Theme,
React.ComponentProps<AnimatedComponentType>,
EnableShorthand
>(AnimatedComponent);
};

export default createAnimatedRestyleComponent;
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './createText';
export {default as createVariant} from './createVariant';
export {default as createBox} from './createBox';
export {default as createText} from './createText';
export {default as createAnimatedRestyleComponent} from './createAnimatedRestyleComponent';
export {ThemeProvider, ThemeContext} from './context';
export {default as useTheme} from './hooks/useTheme';
export {default as useRestyle} from './hooks/useRestyle';
Expand Down
185 changes: 185 additions & 0 deletions src/test/createAnimatedRestyleComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import React from 'react';
import {create as render, act} from 'react-test-renderer';
import {View} from 'react-native';

import createAnimatedRestyleComponent from '../createAnimatedRestyleComponent';
import createRestyleComponent from '../createRestyleComponent';
import createVariant, {VariantProps} from '../createVariant';
import {boxRestyleFunctions, BoxProps} from '../createBox';
import {ThemeProvider} from '../context';

const theme = {
colors: {
primary: '#5A31F4',
},
spacing: {
s: 8,
},
borderRadii: {
sm: 4,
lg: 32,
},
zIndices: {
base: 0,
top: 1,
},
cardVariants: {
defaults: {
borderRadius: 'sm',
},
raised: {
borderRadius: 'lg',
},
},
};

type Theme = typeof theme;

/**
* Stands in for `Animated.createAnimatedComponent(View)` without depending on
* react-native-reanimated in the test suite. It reproduces the exact mechanism
* behind #355: once "settled" (triggered here via the imperative `settle` method,
* standing in for Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS flag), it
* spreads the given raw style values as top-level props onto the plain `View` it
* renders internally — via its own setState/render, never by re-rendering with new
* props passed down from its parent. That's the crux of what ordering fixes: those
* settled props land on this component's own child, not back up on whatever wraps it.
*/
class MockAnimatedView extends React.Component<
{style?: unknown; [key: string]: unknown},
{settled: {[key: string]: unknown} | null}
> {
state: {settled: {[key: string]: unknown} | null} = {settled: null};

// Intentionally public: tests call this through a ref to simulate the
// imperative settle event a real animation library fires internally.
// eslint-disable-next-line @shopify/react-prefer-private-members
settle(values: {[key: string]: unknown}) {
this.setState({settled: values});
}

render() {
const {settled} = this.state;
return <View {...this.props} {...(settled ?? {})} />;
}
}

const AnimatedBox = createAnimatedRestyleComponent<
Theme,
typeof MockAnimatedView
>(MockAnimatedView);

describe('createAnimatedRestyleComponent', () => {
it('does not throw when a settled animation spreads a raw color value', () => {
let instance: MockAnimatedView | null = null;
const {root} = render(
<ThemeProvider theme={theme}>
<AnimatedBox ref={(r: MockAnimatedView | null) => (instance = r)} />
</ThemeProvider>,
);

expect(() => {
act(() => {
instance!.settle({backgroundColor: 'rgba(255,209,102,1)'});
});
}).not.toThrow();

expect(root.findByType(View).props.backgroundColor).toBe(
'rgba(255,209,102,1)',
);
});

it('does not throw when a settled animation spreads raw borderRadius and zIndex values', () => {
let instance: MockAnimatedView | null = null;
const {root} = render(
<ThemeProvider theme={theme}>
<AnimatedBox ref={(r: MockAnimatedView | null) => (instance = r)} />
</ThemeProvider>,
);

expect(() => {
act(() => {
instance!.settle({borderRadius: 13, zIndex: 1});
});
}).not.toThrow();

expect(root.findByType(View).props).toMatchObject({
borderRadius: 13,
zIndex: 1,
});
});

it('still resolves theme tokens normally', () => {
const {root} = render(
<ThemeProvider theme={theme}>
<AnimatedBox backgroundColor="primary" borderRadius="lg" zIndex="top" />
</ThemeProvider>,
);

expect(root.findByType(View).props.style).toStrictEqual([
{backgroundColor: '#5A31F4', borderRadius: 32, zIndex: 1},
]);
});

it("still throws on a genuine typo'd theme key", () => {
expect(() => {
render(
<ThemeProvider theme={theme}>
{/* @ts-expect-error intentionally invalid theme key, to prove typo detection is unaffected */}
<AnimatedBox backgroundColor="primaryy" />
</ThemeProvider>,
);
}).toThrow("Value 'primaryy' does not exist in theme['colors']");
});

it('supports variants when composed with createVariant, same as a non-animated custom component', () => {
// `Box` (and so `createAnimatedRestyleComponent`, which shares its restyle
// functions) doesn't bundle variant support out of the box — a variant-aware
// component is composed explicitly via createRestyleComponent, same as the
// "Custom components" guide shows for non-animated components. This proves
// that composition still works with an animated base component.
const cardVariant = createVariant<Theme, 'cardVariants'>({
themeKey: 'cardVariants',
});
type Props = BoxProps<Theme> &
VariantProps<Theme, 'cardVariants'> &
React.ComponentProps<typeof MockAnimatedView>;
const AnimatedCard = createRestyleComponent<Props, Theme>(
[...boxRestyleFunctions, cardVariant],
MockAnimatedView,
);

const {root} = render(
<ThemeProvider theme={theme}>
<AnimatedCard variant="raised" />
</ThemeProvider>,
);

expect(root.findByType(View).props.style).toStrictEqual([
{borderRadius: 32},
]);
});

it('supports responsive props', () => {
const {root} = render(
<ThemeProvider theme={{...theme, breakpoints: {phone: 0, tablet: 768}}}>
<AnimatedBox borderRadius={{phone: 'sm', tablet: 'lg'}} />
</ThemeProvider>,
);

expect(root.findByType(View).props.style).toStrictEqual([
{borderRadius: 4},
]);
});

it('forwards refs to the wrapped animated component', () => {
const spy = jest.fn();
render(
<ThemeProvider theme={theme}>
<AnimatedBox ref={spy} testID="ANIMATED_BOX" />
</ThemeProvider>,
);

expect(spy).toHaveBeenCalledWith(expect.any(MockAnimatedView));
});
});
Loading