From ec1c7b17a6627455c88bf5e7994c119fd67cfae1 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Tue, 5 May 2026 14:23:53 +0300 Subject: [PATCH] feat: add motion tokens (spring, easing, duration) Adds theme.motion with two preset schemes (expressiveMotion default, standardMotion), M3 easing curves, and duration constants verified against the material.io spec. Includes toRawSpring() helper to convert the spec damping ratio to the raw coefficient expected by Animated.spring and Reanimated --- .../__snapshots__/ListSection.test.tsx.snap | 288 ++++++++++++++++++ src/core/__tests__/PaperProvider.test.tsx | 14 - src/theme/schemes/base.ts | 2 + src/theme/tokens/sys/motion.ts | 99 ++++++ src/theme/types/index.ts | 1 + src/theme/types/motion.ts | 47 +++ src/theme/types/theme.ts | 3 +- 7 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 src/theme/tokens/sys/motion.ts create mode 100644 src/theme/types/motion.ts diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index f49e1240c8..957f5f71f4 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -292,6 +292,102 @@ exports[`renders list section with custom title style 1`] = ` "lineHeight": 20, }, }, + "motion": { + "duration": { + "extraLong1": 700, + "extraLong2": 800, + "extraLong3": 900, + "extraLong4": 1000, + "long1": 450, + "long2": 500, + "long3": 550, + "long4": 600, + "medium1": 250, + "medium2": 300, + "medium3": 350, + "medium4": 400, + "short1": 50, + "short2": 100, + "short3": 150, + "short4": 200, + }, + "easing": { + "emphasized": [ + 0.2, + 0, + 0, + 1, + ], + "emphasizedAccelerate": [ + 0.3, + 0, + 0.8, + 0.15, + ], + "emphasizedDecelerate": [ + 0.05, + 0.7, + 0.1, + 1, + ], + "linear": [ + 0, + 0, + 1, + 1, + ], + "standard": [ + 0.2, + 0, + 0, + 1, + ], + "standardAccelerate": [ + 0.3, + 0, + 1, + 1, + ], + "standardDecelerate": [ + 0, + 0, + 0, + 1, + ], + }, + "spring": { + "default": { + "effects": { + "damping": 1, + "stiffness": 1600, + }, + "spatial": { + "damping": 0.8, + "stiffness": 380, + }, + }, + "fast": { + "effects": { + "damping": 1, + "stiffness": 3800, + }, + "spatial": { + "damping": 0.6, + "stiffness": 800, + }, + }, + "slow": { + "effects": { + "damping": 1, + "stiffness": 800, + }, + "spatial": { + "damping": 0.8, + "stiffness": 200, + }, + }, + }, + }, "shapes": { "corner": { "extraExtraLarge": 48, @@ -950,6 +1046,102 @@ exports[`renders list section with subheader 1`] = ` "lineHeight": 20, }, }, + "motion": { + "duration": { + "extraLong1": 700, + "extraLong2": 800, + "extraLong3": 900, + "extraLong4": 1000, + "long1": 450, + "long2": 500, + "long3": 550, + "long4": 600, + "medium1": 250, + "medium2": 300, + "medium3": 350, + "medium4": 400, + "short1": 50, + "short2": 100, + "short3": 150, + "short4": 200, + }, + "easing": { + "emphasized": [ + 0.2, + 0, + 0, + 1, + ], + "emphasizedAccelerate": [ + 0.3, + 0, + 0.8, + 0.15, + ], + "emphasizedDecelerate": [ + 0.05, + 0.7, + 0.1, + 1, + ], + "linear": [ + 0, + 0, + 1, + 1, + ], + "standard": [ + 0.2, + 0, + 0, + 1, + ], + "standardAccelerate": [ + 0.3, + 0, + 1, + 1, + ], + "standardDecelerate": [ + 0, + 0, + 0, + 1, + ], + }, + "spring": { + "default": { + "effects": { + "damping": 1, + "stiffness": 1600, + }, + "spatial": { + "damping": 0.8, + "stiffness": 380, + }, + }, + "fast": { + "effects": { + "damping": 1, + "stiffness": 3800, + }, + "spatial": { + "damping": 0.6, + "stiffness": 800, + }, + }, + "slow": { + "effects": { + "damping": 1, + "stiffness": 800, + }, + "spatial": { + "damping": 0.8, + "stiffness": 200, + }, + }, + }, + }, "shapes": { "corner": { "extraExtraLarge": 48, @@ -1606,6 +1798,102 @@ exports[`renders list section without subheader 1`] = ` "lineHeight": 20, }, }, + "motion": { + "duration": { + "extraLong1": 700, + "extraLong2": 800, + "extraLong3": 900, + "extraLong4": 1000, + "long1": 450, + "long2": 500, + "long3": 550, + "long4": 600, + "medium1": 250, + "medium2": 300, + "medium3": 350, + "medium4": 400, + "short1": 50, + "short2": 100, + "short3": 150, + "short4": 200, + }, + "easing": { + "emphasized": [ + 0.2, + 0, + 0, + 1, + ], + "emphasizedAccelerate": [ + 0.3, + 0, + 0.8, + 0.15, + ], + "emphasizedDecelerate": [ + 0.05, + 0.7, + 0.1, + 1, + ], + "linear": [ + 0, + 0, + 1, + 1, + ], + "standard": [ + 0.2, + 0, + 0, + 1, + ], + "standardAccelerate": [ + 0.3, + 0, + 1, + 1, + ], + "standardDecelerate": [ + 0, + 0, + 0, + 1, + ], + }, + "spring": { + "default": { + "effects": { + "damping": 1, + "stiffness": 1600, + }, + "spatial": { + "damping": 0.8, + "stiffness": 380, + }, + }, + "fast": { + "effects": { + "damping": 1, + "stiffness": 3800, + }, + "spatial": { + "damping": 0.6, + "stiffness": 800, + }, + }, + "slow": { + "effects": { + "damping": 1, + "stiffness": 800, + }, + "spatial": { + "damping": 0.8, + "stiffness": 200, + }, + }, + }, + }, "shapes": { "corner": { "extraExtraLarge": 48, diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 1d04ec00ff..7b9b554ff7 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -122,20 +122,6 @@ describe('PaperProvider', () => { ); }); - it('handles overriding animation with the custom one', () => { - const { getByTestId } = render( - createProvider({ - ...LightTheme, - animation: { defaultAnimationDuration: 250 }, - }) - ); - - expect(getByTestId('provider-child-view').props.theme).toStrictEqual({ - ...LightTheme, - animation: { scale: 1, defaultAnimationDuration: 250 }, - }); - }); - it('should set AccessibilityInfo listeners, if there is no theme', async () => { mockAppearance(); mockAccessibilityInfo(); diff --git a/src/theme/schemes/base.ts b/src/theme/schemes/base.ts index 44d3a017e9..ba8ebdd15d 100644 --- a/src/theme/schemes/base.ts +++ b/src/theme/schemes/base.ts @@ -1,3 +1,4 @@ +import { expressiveMotion } from '../tokens/sys/motion'; import { defaultShapes } from '../tokens/sys/shape'; import { defaultFonts } from '../tokens/sys/typography'; import type { Theme } from '../types'; @@ -10,4 +11,5 @@ export const themeDefaults: ThemeDefaults = { }, fonts: defaultFonts, shapes: defaultShapes, + motion: expressiveMotion, }; diff --git a/src/theme/tokens/sys/motion.ts b/src/theme/tokens/sys/motion.ts new file mode 100644 index 0000000000..a7c96454f2 --- /dev/null +++ b/src/theme/tokens/sys/motion.ts @@ -0,0 +1,99 @@ +import type { + MotionConfig, + MotionDuration, + MotionEasing, + SpringConfig, +} from '../../types'; + +// Spring, easing curves and duration constants per the M3 spec: +// https://m3.material.io/styles/motion/easing-and-duration/tokens-specs + +const expressiveSpring = { + spring: { + fast: { + spatial: { stiffness: 800, damping: 0.6 }, + effects: { stiffness: 3800, damping: 1 }, + }, + default: { + spatial: { stiffness: 380, damping: 0.8 }, + effects: { stiffness: 1600, damping: 1 }, + }, + slow: { + spatial: { stiffness: 200, damping: 0.8 }, + effects: { stiffness: 800, damping: 1 }, + }, + }, +}; + +const standardSpring = { + spring: { + fast: { + spatial: { stiffness: 1400, damping: 0.9 }, + effects: { stiffness: 3800, damping: 1 }, + }, + default: { + spatial: { stiffness: 700, damping: 0.9 }, + effects: { stiffness: 1600, damping: 1 }, + }, + slow: { + spatial: { stiffness: 300, damping: 0.9 }, + effects: { stiffness: 800, damping: 1 }, + }, + }, +}; + +export const motionEasing: MotionEasing = { + emphasized: [0.2, 0, 0, 1], + emphasizedAccelerate: [0.3, 0, 0.8, 0.15], + emphasizedDecelerate: [0.05, 0.7, 0.1, 1], + standard: [0.2, 0, 0, 1], + standardAccelerate: [0.3, 0, 1, 1], + standardDecelerate: [0, 0, 0, 1], + linear: [0, 0, 1, 1], +}; + +export const motionDuration: MotionDuration = { + short1: 50, + short2: 100, + short3: 150, + short4: 200, + medium1: 250, + medium2: 300, + medium3: 350, + medium4: 400, + long1: 450, + long2: 500, + long3: 550, + long4: 600, + extraLong1: 700, + extraLong2: 800, + extraLong3: 900, + extraLong4: 1000, +}; + +export const expressiveMotion: MotionConfig = { + ...expressiveSpring, + easing: motionEasing, + duration: motionDuration, +}; + +export const standardMotion: MotionConfig = { + ...standardSpring, + easing: motionEasing, + duration: motionDuration, +}; + +/** + * Converts a `SpringConfig` (spec damping ratio 0–1) to the raw damping + * coefficient expected by `Animated.spring` and Reanimated's `withSpring`. + * + * @example + * Animated.spring(value, { + * toValue: 0.85, + * ...toRawSpring(theme.motion.spring.fast.spatial), + * useNativeDriver: true, + * }); + */ +export function toRawSpring({ stiffness, damping }: SpringConfig) { + return { stiffness, damping: damping * 2 * Math.sqrt(stiffness) }; +} diff --git a/src/theme/types/index.ts b/src/theme/types/index.ts index e62ef4ad45..cc1ffe21ef 100644 --- a/src/theme/types/index.ts +++ b/src/theme/types/index.ts @@ -1,5 +1,6 @@ export * from './color'; export * from './elevation'; +export * from './motion'; export * from './navigation'; export * from './shape'; export * from './theme'; diff --git a/src/theme/types/motion.ts b/src/theme/types/motion.ts new file mode 100644 index 0000000000..a0e037f524 --- /dev/null +++ b/src/theme/types/motion.ts @@ -0,0 +1,47 @@ +export type SpringConfig = { + stiffness: number; + damping: number; // damping ratio 0–1; matches md.sys.motion.spring.*.*.damping +}; + +export type MotionSpring = { + fast: { spatial: SpringConfig; effects: SpringConfig }; + default: { spatial: SpringConfig; effects: SpringConfig }; + slow: { spatial: SpringConfig; effects: SpringConfig }; +}; + +export type EasingConfig = readonly [number, number, number, number]; + +export type MotionEasing = { + emphasized: EasingConfig; + emphasizedAccelerate: EasingConfig; + emphasizedDecelerate: EasingConfig; + standard: EasingConfig; + standardAccelerate: EasingConfig; + standardDecelerate: EasingConfig; + linear: EasingConfig; +}; + +export type MotionDuration = { + short1: number; + short2: number; + short3: number; + short4: number; + medium1: number; + medium2: number; + medium3: number; + medium4: number; + long1: number; + long2: number; + long3: number; + long4: number; + extraLong1: number; + extraLong2: number; + extraLong3: number; + extraLong4: number; +}; + +export type MotionConfig = { + spring: MotionSpring; + easing: MotionEasing; + duration: MotionDuration; +}; diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts index dac2946cc9..a067fdec12 100644 --- a/src/theme/types/theme.ts +++ b/src/theme/types/theme.ts @@ -1,6 +1,7 @@ import type { $DeepPartial } from '@callstack/react-theme-provider'; import type { ThemeColors } from './color'; +import type { MotionConfig } from './motion'; import type { ThemeShapes } from './shape'; import type { Typescale } from './typography'; @@ -11,7 +12,6 @@ export type ThemeBase = { mode?: Mode; animation: { scale: number; - defaultAnimationDuration?: number; }; }; @@ -19,6 +19,7 @@ export type Theme = ThemeBase & { colors: ThemeColors; fonts: Typescale; shapes: ThemeShapes; + motion: MotionConfig; }; export type InternalTheme = Theme;