@aphrody/m3-motion 3.3.2 → 3.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { AnimatePresence, motion } from "motion/react";
4
+ import type { HTMLMotionProps, MotionStyle, Transition, Variants } from "motion/react";
5
+ import { m3Easings, m3Durations } from "../easings.js";
6
+ import { motionVariants, useM3ReducedMotion } from "../hooks/useM3ReducedMotion.js";
7
+
8
+ export type M3TransitionPattern =
9
+ | "fade"
10
+ | "fade-through"
11
+ | "shared-axis-x"
12
+ | "shared-axis-y"
13
+ | "shared-axis-z"
14
+ | "collapse";
15
+
16
+ export interface M3TransitionProps extends Omit<HTMLMotionProps<"div">, "variants" | "style"> {
17
+ style?: MotionStyle;
18
+ /**
19
+ * Unique key representing the active view/content. Changing this key triggers the transition.
20
+ */
21
+ stateKey: React.Key;
22
+ /**
23
+ * Which M3 transition pattern to apply.
24
+ * @default "fade"
25
+ */
26
+ pattern?: M3TransitionPattern;
27
+ /**
28
+ * Direction of navigation for shared-axis-* patterns.
29
+ * @default true
30
+ */
31
+ forward?: boolean;
32
+ /**
33
+ * Custom variants — overrides the pattern defaults entirely.
34
+ */
35
+ variants?: Variants;
36
+ /**
37
+ * AnimatePresence mode.
38
+ * @default "wait"
39
+ */
40
+ mode?: "wait" | "sync" | "popLayout";
41
+ }
42
+
43
+ const OFFSET = 30;
44
+ const SCALE_OFFSET = 0.08;
45
+
46
+ function buildVariants(pattern: M3TransitionPattern, forward: boolean): Variants {
47
+ const enterTransition: Transition = {
48
+ ease: m3Easings.emphasizedDecelerate,
49
+ duration: m3Durations.medium2,
50
+ };
51
+ const exitTransition: Transition = {
52
+ ease: m3Easings.emphasizedAccelerate,
53
+ duration: m3Durations.short4,
54
+ };
55
+
56
+ switch (pattern) {
57
+ case "fade-through":
58
+ return {
59
+ initial: { opacity: 0, scale: 0.92 },
60
+ animate: {
61
+ opacity: 1,
62
+ scale: 1,
63
+ transition: {
64
+ opacity: { ease: m3Easings.emphasizedDecelerate, duration: 0.21 },
65
+ scale: { ease: m3Easings.emphasizedDecelerate, duration: 0.21 },
66
+ },
67
+ },
68
+ exit: {
69
+ opacity: 0,
70
+ transition: { opacity: { ease: m3Easings.emphasizedAccelerate, duration: 0.09 } },
71
+ },
72
+ };
73
+ case "shared-axis-x":
74
+ return {
75
+ initial: { opacity: 0, x: forward ? OFFSET : -OFFSET },
76
+ animate: {
77
+ opacity: 1,
78
+ x: 0,
79
+ transition: {
80
+ x: enterTransition,
81
+ opacity: { ease: m3Easings.standard, duration: m3Durations.medium2 },
82
+ },
83
+ },
84
+ exit: {
85
+ opacity: 0,
86
+ x: forward ? -OFFSET : OFFSET,
87
+ transition: {
88
+ x: exitTransition,
89
+ opacity: { ease: m3Easings.standard, duration: m3Durations.short4 },
90
+ },
91
+ },
92
+ };
93
+ case "shared-axis-y":
94
+ return {
95
+ initial: { opacity: 0, y: forward ? OFFSET : -OFFSET },
96
+ animate: {
97
+ opacity: 1,
98
+ y: 0,
99
+ transition: {
100
+ y: enterTransition,
101
+ opacity: { ease: m3Easings.standard, duration: m3Durations.medium2 },
102
+ },
103
+ },
104
+ exit: {
105
+ opacity: 0,
106
+ y: forward ? -OFFSET : OFFSET,
107
+ transition: {
108
+ y: exitTransition,
109
+ opacity: { ease: m3Easings.standard, duration: m3Durations.short4 },
110
+ },
111
+ },
112
+ };
113
+ case "shared-axis-z":
114
+ return {
115
+ initial: { opacity: 0, scale: forward ? 1 - SCALE_OFFSET : 1 + SCALE_OFFSET },
116
+ animate: {
117
+ opacity: 1,
118
+ scale: 1,
119
+ transition: {
120
+ scale: enterTransition,
121
+ opacity: { ease: m3Easings.standard, duration: m3Durations.medium2 },
122
+ },
123
+ },
124
+ exit: {
125
+ opacity: 0,
126
+ scale: forward ? 1 + SCALE_OFFSET : 1 - SCALE_OFFSET,
127
+ transition: {
128
+ scale: exitTransition,
129
+ opacity: { ease: m3Easings.standard, duration: m3Durations.short4 },
130
+ },
131
+ },
132
+ };
133
+ case "collapse":
134
+ return {
135
+ initial: { height: 0, opacity: 0 },
136
+ animate: {
137
+ height: "auto",
138
+ opacity: 1,
139
+ transition: {
140
+ height: { ease: m3Easings.emphasized, duration: m3Durations.medium4 },
141
+ opacity: { ease: m3Easings.standard, duration: m3Durations.medium2 },
142
+ },
143
+ },
144
+ exit: {
145
+ height: 0,
146
+ opacity: 0,
147
+ transition: {
148
+ height: { ease: m3Easings.emphasized, duration: m3Durations.medium4 },
149
+ opacity: { ease: m3Easings.standard, duration: m3Durations.medium2 },
150
+ },
151
+ },
152
+ };
153
+ case "fade":
154
+ default:
155
+ return {
156
+ initial: { opacity: 0 },
157
+ animate: { opacity: 1, transition: enterTransition },
158
+ exit: { opacity: 0, transition: exitTransition },
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * M3Transition — generic Material 3 transition wrapper.
165
+ * Supports all standard M3 patterns via the `pattern` prop.
166
+ * The `stateKey` prop drives AnimatePresence re-mounts.
167
+ */
168
+ export const M3Transition = React.forwardRef<HTMLDivElement, M3TransitionProps>(
169
+ (
170
+ {
171
+ stateKey,
172
+ pattern = "fade",
173
+ forward = true,
174
+ mode = "wait",
175
+ variants,
176
+ children,
177
+ style,
178
+ ...props
179
+ },
180
+ ref,
181
+ ) => {
182
+ const reduced = useM3ReducedMotion();
183
+ const resolvedVariants = motionVariants(variants ?? buildVariants(pattern, forward), reduced);
184
+ const baseStyle: MotionStyle =
185
+ pattern === "collapse" ? { overflow: "hidden" } : { willChange: "opacity, transform" };
186
+ const resolvedStyle: MotionStyle = { ...baseStyle, ...style };
187
+
188
+ return (
189
+ <AnimatePresence mode={mode}>
190
+ <motion.div
191
+ key={stateKey}
192
+ ref={ref}
193
+ variants={resolvedVariants}
194
+ initial={reduced ? false : "initial"}
195
+ animate="animate"
196
+ exit="exit"
197
+ style={resolvedStyle}
198
+ {...props}
199
+ >
200
+ {children}
201
+ </motion.div>
202
+ </AnimatePresence>
203
+ );
204
+ },
205
+ );
206
+
207
+ M3Transition.displayName = "M3Transition";
package/src/easings.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Material Design 3 Easing Presets.
3
+ * Represented as cubic-bezier array coordinates [x1, y1, x2, y2].
4
+ *
5
+ * Source of truth: @aphrody/material-web/tokens/versions/v0_192/_md-sys-motion.scss
6
+ * and docs/01-md3-spec-foundations.md §7.1
7
+ *
8
+ * Note on `emphasized`: the full M3 emphasized path is a 2-segment spline
9
+ * (not expressible as a single cubic-bezier). The token file maps
10
+ * easing-emphasized to cubic-bezier(0.2, 0, 0, 1) — identical to standard —
11
+ * because that is the closest single-bezier approximation used in web
12
+ * implementations. Use emphasizedDecelerate / emphasizedAccelerate for
13
+ * enter/exit transitions respectively (recommended practice per spec §7.1).
14
+ */
15
+ export const m3Easings = {
16
+ /** Closest single-bezier approximation. Use emphasizedDecelerate/Accelerate for transitions. */
17
+ emphasized: [0.2, 0.0, 0.0, 1.0],
18
+ emphasizedDecelerate: [0.05, 0.7, 0.1, 1.0],
19
+ emphasizedAccelerate: [0.3, 0.0, 0.8, 0.15],
20
+ standard: [0.2, 0.0, 0.0, 1.0],
21
+ standardDecelerate: [0.0, 0.0, 0.0, 1.0],
22
+ standardAccelerate: [0.3, 0.0, 1.0, 1.0],
23
+ linear: [0.0, 0.0, 1.0, 1.0],
24
+ /** Legacy (M2 default) — kept for migration compatibility. */
25
+ legacy: [0.4, 0.0, 0.2, 1.0],
26
+ legacyDecelerate: [0.0, 0.0, 0.2, 1.0],
27
+ legacyAccelerate: [0.4, 0.0, 1.0, 1.0],
28
+ } as const;
29
+
30
+ export type M3EasingKey = keyof typeof m3Easings;
31
+ export type M3EasingValue = (typeof m3Easings)[M3EasingKey];
32
+
33
+ /**
34
+ * Material Design 3 Duration Presets (in seconds, compatible with Motion duration option).
35
+ *
36
+ * Canonical values from _md-sys-motion.scss:
37
+ * short1=50ms, short2=100ms, short3=150ms, short4=200ms
38
+ * medium1=250ms, medium2=300ms, medium3=350ms, medium4=400ms
39
+ * long1=450ms, long2=500ms, long3=550ms, long4=600ms
40
+ * extraLong1=700ms, extraLong2=800ms, extraLong3=900ms, extraLong4=1000ms
41
+ */
42
+ export const m3Durations = {
43
+ short1: 0.05,
44
+ short2: 0.1,
45
+ short3: 0.15,
46
+ short4: 0.2,
47
+ medium1: 0.25,
48
+ medium2: 0.3,
49
+ medium3: 0.35,
50
+ medium4: 0.4,
51
+ long1: 0.45,
52
+ long2: 0.5,
53
+ long3: 0.55,
54
+ long4: 0.6,
55
+ extraLong1: 0.7,
56
+ extraLong2: 0.8,
57
+ extraLong3: 0.9,
58
+ extraLong4: 1.0,
59
+ } as const;
60
+
61
+ export type M3DurationKey = keyof typeof m3Durations;
62
+ export type M3DurationValue = (typeof m3Durations)[M3DurationKey];
@@ -0,0 +1,93 @@
1
+ import { useAnimate } from "motion/react";
2
+ import type {
3
+ AnimationScope,
4
+ AnimationPlaybackControlsWithThen,
5
+ AnimationSequence,
6
+ SequenceOptions,
7
+ ElementOrSelector,
8
+ DOMKeyframesDefinition,
9
+ AnimationOptions,
10
+ MotionValue,
11
+ UnresolvedValueKeyframe,
12
+ ValueAnimationTransition,
13
+ ObjectTarget,
14
+ } from "motion/react";
15
+ import { m3Easings, m3Durations } from "../easings.js";
16
+ import { useM3ReducedMotion } from "./useM3ReducedMotion.js";
17
+
18
+ /**
19
+ * Overloaded type for the scoped animate function returned by useM3Animate.
20
+ * Mirrors the overload set of the underlying motion animate, adding M3 defaults.
21
+ */
22
+ export interface M3AnimateFn {
23
+ (sequence: AnimationSequence, options?: SequenceOptions): AnimationPlaybackControlsWithThen;
24
+ (
25
+ value: string | MotionValue<string>,
26
+ keyframes: string | UnresolvedValueKeyframe<string>[],
27
+ options?: ValueAnimationTransition<string>,
28
+ ): AnimationPlaybackControlsWithThen;
29
+ (
30
+ value: number | MotionValue<number>,
31
+ keyframes: number | UnresolvedValueKeyframe<number>[],
32
+ options?: ValueAnimationTransition<number>,
33
+ ): AnimationPlaybackControlsWithThen;
34
+ <V extends string | number>(
35
+ value: V | MotionValue<V>,
36
+ keyframes: V | UnresolvedValueKeyframe<V>[],
37
+ options?: ValueAnimationTransition<V>,
38
+ ): AnimationPlaybackControlsWithThen;
39
+ (
40
+ element: ElementOrSelector,
41
+ keyframes: DOMKeyframesDefinition,
42
+ options?: AnimationOptions,
43
+ ): AnimationPlaybackControlsWithThen;
44
+ <O extends object>(
45
+ object: O | O[],
46
+ keyframes: ObjectTarget<O>,
47
+ options?: AnimationOptions,
48
+ ): AnimationPlaybackControlsWithThen;
49
+ }
50
+
51
+ /**
52
+ * A custom hook wrapping motion's `useAnimate` that injects Material 3 motion defaults
53
+ * (emphasized easing and medium2 duration) for all imperative DOM animations.
54
+ *
55
+ * SSR-safe: useAnimate creates refs; no DOM access occurs at module or render time.
56
+ */
57
+ export function useM3Animate<T extends Element = HTMLElement>(): [AnimationScope<T>, M3AnimateFn] {
58
+ const [scope, animate] = useAnimate<T>();
59
+ const reduced = useM3ReducedMotion();
60
+
61
+ const m3Animate: M3AnimateFn = (
62
+ elementOrSelector: unknown,
63
+ keyframes?: unknown,
64
+ options?: unknown,
65
+ ) => {
66
+ const defaults = { ease: m3Easings.emphasized, duration: m3Durations.medium2 };
67
+ const immediate = reduced ? { type: "tween", duration: 0, delay: 0, repeat: 0 } : {};
68
+ if (Array.isArray(elementOrSelector) && elementOrSelector.some(Array.isArray)) {
69
+ // Sequence overload
70
+ const sequenceOptions =
71
+ typeof keyframes === "object" && keyframes !== null ? (keyframes as SequenceOptions) : {};
72
+ const sequence = reduced
73
+ ? elementOrSelector.map((segment) =>
74
+ Array.isArray(segment)
75
+ ? [segment[0], segment[1], { ...segment[2], ...immediate, at: 0 }]
76
+ : segment,
77
+ )
78
+ : elementOrSelector;
79
+ return (animate as CallableFunction)(sequence, {
80
+ ...sequenceOptions,
81
+ defaultTransition: { ...defaults, ...sequenceOptions.defaultTransition, ...immediate },
82
+ }) as AnimationPlaybackControlsWithThen;
83
+ }
84
+
85
+ return (animate as CallableFunction)(elementOrSelector, keyframes, {
86
+ ...defaults,
87
+ ...(typeof options === "object" && options !== null ? options : {}),
88
+ ...immediate,
89
+ }) as AnimationPlaybackControlsWithThen;
90
+ };
91
+
92
+ return [scope, m3Animate];
93
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { useSyncExternalStore } from "react";
5
+ import type { TargetAndTransition, Variants } from "motion/react";
6
+
7
+ const QUERY = "(prefers-reduced-motion: reduce)";
8
+
9
+ function subscribe(onChange: () => void): () => void {
10
+ if (typeof window === "undefined" || !window.matchMedia) return () => {};
11
+ const media = window.matchMedia(QUERY);
12
+ media.addEventListener("change", onChange);
13
+ return () => media.removeEventListener("change", onChange);
14
+ }
15
+
16
+ /** Tracks the OS preference, including changes made while an application is open. */
17
+ export function useM3ReducedMotion(): boolean {
18
+ return useSyncExternalStore(
19
+ subscribe,
20
+ () => typeof window !== "undefined" && !!window.matchMedia?.(QUERY).matches,
21
+ () => false,
22
+ );
23
+ }
24
+
25
+ function immediate(target: TargetAndTransition): TargetAndTransition;
26
+ function immediate(target: TargetAndTransition | string): TargetAndTransition | string;
27
+ function immediate(target: TargetAndTransition | string): TargetAndTransition | string {
28
+ if (typeof target === "string") return target;
29
+ return { ...target, transition: { type: "tween", duration: 0, delay: 0 } };
30
+ }
31
+
32
+ /** Preserve each state and custom variant while removing animation and stagger delays. */
33
+ export function motionVariants(variants: Variants, reduced: boolean): Variants {
34
+ if (!reduced) return variants;
35
+ return Object.fromEntries(
36
+ Object.entries(variants).map(([key, variant]) => [
37
+ key,
38
+ typeof variant === "function"
39
+ ? (...args: Parameters<typeof variant>) => immediate(variant(...args))
40
+ : immediate(variant),
41
+ ]),
42
+ );
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from "./springs.js";
2
+ export * from "./easings.js";
3
+ export * from "./spring-interpolation.js";
4
+ export * from "./components/M3Fade.js";
5
+ export * from "./components/M3SharedAxis.js";
6
+ export * from "./components/M3FadeThrough.js";
7
+ export * from "./components/M3Collapse.js";
8
+ export * from "./components/M3ContainerTransform.js";
9
+ export * from "./components/M3Transition.js";
10
+ export * from "./components/M3ListStagger.js";
11
+ export * from "./hooks/useM3Animate.js";
12
+ export { useM3ReducedMotion } from "./hooks/useM3ReducedMotion.js";