From f1e1cf14b397a31463287ed2acc69d99b43bb92c 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. Deprecates animation.defaultAnimationDuration in favour of theme.motion.duration.*. No component consumes the tokens yet. --- .../__snapshots__/ListSection.test.tsx.snap | 342 ++++++++++++++++++ src/index.tsx | 5 + src/theme/schemes/base.ts | 2 + src/theme/tokens/sys/motion.ts | 102 ++++++ src/theme/types/index.ts | 1 + src/theme/types/motion.ts | 50 +++ src/theme/types/theme.ts | 3 + 7 files changed, 505 insertions(+) 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 bc9a7a5380..66123507e2 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -292,6 +292,120 @@ 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, + ], + "legacy": [ + 0.4, + 0, + 0.2, + 1, + ], + "legacyAccelerate": [ + 0.4, + 0, + 1, + 1, + ], + "legacyDecelerate": [ + 0, + 0, + 0.2, + 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, + }, + }, + }, + }, "roundness": 4, "shapes": { "extraExtraLarge": 48, @@ -961,6 +1075,120 @@ 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, + ], + "legacy": [ + 0.4, + 0, + 0.2, + 1, + ], + "legacyAccelerate": [ + 0.4, + 0, + 1, + 1, + ], + "legacyDecelerate": [ + 0, + 0, + 0.2, + 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, + }, + }, + }, + }, "roundness": 4, "shapes": { "extraExtraLarge": 48, @@ -1628,6 +1856,120 @@ 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, + ], + "legacy": [ + 0.4, + 0, + 0.2, + 1, + ], + "legacyAccelerate": [ + 0.4, + 0, + 1, + 1, + ], + "legacyDecelerate": [ + 0, + 0, + 0.2, + 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, + }, + }, + }, + }, "roundness": 4, "shapes": { "extraExtraLarge": 48, diff --git a/src/index.tsx b/src/index.tsx index 4f45286d6b..6160e89c30 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,11 @@ export { default as PaperProvider } from './core/PaperProvider'; export { default as shadow } from './theme/shadow'; export { default as configureFonts } from './theme/fonts'; export { cornersToStyle } from './theme/tokens/sys/shape'; +export { + expressiveMotion, + standardMotion, + toRawSpring, +} from './theme/tokens/sys/motion'; import * as Avatar from './components/Avatar/Avatar'; import * as Drawer from './components/Drawer/Drawer'; diff --git a/src/theme/schemes/base.ts b/src/theme/schemes/base.ts index afdd76d289..47d5e20c8e 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 { defaultState } from '../tokens/sys/state'; import { defaultFonts } from '../tokens/sys/typography'; @@ -13,4 +14,5 @@ export const themeDefaults: ThemeDefaults = { fonts: defaultFonts, state: defaultState, 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..9bc1d37a72 --- /dev/null +++ b/src/theme/tokens/sys/motion.ts @@ -0,0 +1,102 @@ +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], + legacy: [0.4, 0, 0.2, 1], + legacyAccelerate: [0.4, 0, 1, 1], + legacyDecelerate: [0, 0, 0.2, 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 eff2bd98a9..c70919e6ec 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 './state'; diff --git a/src/theme/types/motion.ts b/src/theme/types/motion.ts new file mode 100644 index 0000000000..9f66491e8b --- /dev/null +++ b/src/theme/types/motion.ts @@ -0,0 +1,50 @@ +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; + legacy: EasingConfig; + legacyAccelerate: EasingConfig; + legacyDecelerate: 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 7eabd8be12..0d22e3fab4 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 { ThemeState } from './state'; import type { Typescale } from './typography'; @@ -14,6 +15,7 @@ export type ThemeBase = { roundness: number; animation: { scale: number; + /** @deprecated Use `theme.motion.duration.*` instead. Will be removed in a future version. */ defaultAnimationDuration?: number; }; }; @@ -23,6 +25,7 @@ export type Theme = ThemeBase & { fonts: Typescale; state: ThemeState; shapes: ThemeShapes; + motion: MotionConfig; }; export type InternalTheme = Theme;