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;