@carbon/motion 11.48.0 → 11.49.0

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,442 @@
1
+ # Motion surfaces architecture spike
2
+
3
+ This spike covers named motion surfaces shared across Carbon:
4
+
5
+ - `disclosure`: accordion / table-row expand — reveal in place
6
+ - `contextual`: icon > tooltip / popover — reveal with opacity and scale
7
+ - `expand`: card / tile > side-panel / tearsheet — shared-element morph
8
+ - `invoke`: button > modal / menu / popover — shared-element morph from the
9
+ trigger
10
+
11
+ ## Goal
12
+
13
+ The goal of this spike is to test a simple architecture where:
14
+
15
+ - motion surface definitions live in `@carbon/motion`
16
+ - Sass values are generated from the same source definition
17
+ - React owns lifecycle and presence logic
18
+ - Motion runs reveal and shared-element animations
19
+ - Carbon components keep ownership of layout, focus, dialog behavior, and
20
+ semantics
21
+
22
+ ## Files created
23
+
24
+ ### `packages/motion/src/surfaces.ts`
25
+
26
+ This file defines the shared motion surface data.
27
+
28
+ What we added:
29
+
30
+ - the `disclosure`, `contextual`, `expand`, and `invoke` surfaces
31
+ - reveal vs shared-element surface kinds
32
+ - optional enter / exit keyframes on shared-element surfaces (used by `expand`
33
+ for a CSS-replicable opacity / scale layer)
34
+ - optional `origin: 'trigger'` on shared-element surfaces (used by `invoke`)
35
+ - `getMotionSurface()`
36
+ - `MotionSurfaceName`
37
+
38
+ Why:
39
+
40
+ - to keep the motion intent in one shared place
41
+ - to avoid putting React or Motion-specific values in the base definition
42
+ - so Sass, React, and future engines can read the same named intents
43
+
44
+ What it uses:
45
+
46
+ - `DurationName`
47
+ - `EasingName`
48
+ - `EasingMode`
49
+
50
+ ### `packages/motion/tasks/build.mjs`
51
+
52
+ This file generates Sass from the TypeScript surface definition.
53
+
54
+ What we added:
55
+
56
+ - a build script that reads the exported `surfaces`
57
+ - conversion from JavaScript values to Sass values
58
+ - output generation for `scss/generated/_surfaces.scss`
59
+
60
+ Why:
61
+
62
+ - to keep TypeScript and Sass in sync
63
+ - to follow the request that root definitions live in `@carbon/motion`
64
+
65
+ What it uses:
66
+
67
+ - `@carbon/scss-generator`
68
+ - Node `fs/promises`
69
+ - Node `path`
70
+
71
+ ### `packages/motion/scss/generated/_surfaces.scss`
72
+
73
+ This is a generated file.
74
+
75
+ What it contains:
76
+
77
+ - the Sass map for `$surfaces`
78
+
79
+ Why:
80
+
81
+ - so Sass can read the same surface data used by JavaScript
82
+
83
+ ### `packages/react/src/internal/motion/useMotionSurface.ts`
84
+
85
+ This file is the internal React helper that resolves a named surface into
86
+ Motion-ready values.
87
+
88
+ What we added:
89
+
90
+ - token resolution from `@carbon/motion` (duration and easing names into seconds
91
+ and cubic-bezier tuples)
92
+ - reveal surfaces resolved to `initial` / `animate` / `exit` targets
93
+ - shared-element surfaces resolved to enter / exit transitions
94
+ - optional `animate` / `exit` keyframes on shared-element surfaces when the
95
+ definition includes them (`expand`)
96
+ - reduced-motion gating through `useMotionEnabled()`
97
+
98
+ Why:
99
+
100
+ - to keep Motion-specific numeric forms out of `@carbon/motion`
101
+ - to create a reusable React helper for every surface name
102
+
103
+ What it uses:
104
+
105
+ - `getMotionSurface()`
106
+ - `resolveDuration()`
107
+ - `resolveEasing()`
108
+ - `useMotionEnabled()`
109
+ - types from `motion/react`
110
+
111
+ ### `packages/react/src/internal/motion/useMotionEnabled.ts`
112
+
113
+ This file is the accessibility gate for the surface API.
114
+
115
+ What we added:
116
+
117
+ - a hook that returns whether surface motion should run
118
+ - a bail-out when the user prefers reduced motion
119
+
120
+ Why:
121
+
122
+ - surfaces never animate when users request reduced motion
123
+ - components fall back to default rendering (and their baseline CSS transitions)
124
+
125
+ What it uses:
126
+
127
+ - `useReducedMotion` from `motion/react`
128
+
129
+ ### `packages/react/src/internal/motion/MotionSurface.tsx`
130
+
131
+ This file is the React surface entrypoint.
132
+
133
+ What we added:
134
+
135
+ - `MotionSurface` for reveal and shared-element destinations
136
+ - `MotionSurfaceOrigin` for shared-element sources, paired by `surfaceId`
137
+ - `layoutId` morphs for shared-element surfaces via Motion React
138
+ - optional opacity / scale keyframes on top of the morph when the surface
139
+ defines enter / exit (`expand`)
140
+ - `AnimatePresence` for enter / exit lifecycle
141
+ - a hold on enclosing Carbon presence (for example `ModalPresence`) until
142
+ Motion's exit finishes
143
+ - immediate mount / unmount with no Motion when reduced motion is on
144
+
145
+ Why:
146
+
147
+ - to apply a named surface without rewriting Carbon components as Motion
148
+ components
149
+ - to keep presence and reduced-motion behavior in one place
150
+
151
+ What it uses:
152
+
153
+ - `useMotionSurface()`
154
+ - `AnimatePresence` and `motion` from `motion/react`
155
+ - `PresenceHoldContext`
156
+
157
+ ### `packages/react/src/internal/motion/__tests__/useMotionSurface-test.js`
158
+
159
+ This file tests the React surface resolver.
160
+
161
+ What we added:
162
+
163
+ - shared-element resolution tests for `expand` and `invoke`
164
+ - reveal resolution tests for `contextual`
165
+ - reduced-motion / `enabled` gating test
166
+
167
+ ### `packages/react/src/internal/motion/__tests__/MotionSurface-test.js`
168
+
169
+ This file tests the React surface components.
170
+
171
+ What we added:
172
+
173
+ - open rendering test
174
+ - reveal enter / exit behavior
175
+ - shared-element pairing with `MotionSurfaceOrigin`
176
+ - reduced-motion mount / unmount behavior
177
+ - `onExitComplete` behavior
178
+
179
+ ### `packages/react/src/internal/motion/__tests__/MotionSurfacePresence-test.js`
180
+
181
+ This file tests presence hold integration.
182
+
183
+ What we added:
184
+
185
+ - hold-until-exit behavior when a Carbon presence context is present
186
+ - reopen-during-exit behavior
187
+
188
+ ### `packages/react/src/internal/motion/__tests__/useMotionEnabled-test.js`
189
+
190
+ This file tests the reduced-motion gate.
191
+
192
+ ### `packages/react/src/components/Motion/Expand.stories.js`
193
+
194
+ This file is the Storybook proof of concept for the `expand` surface.
195
+
196
+ What we added:
197
+
198
+ - a grid of Carbon `ClickableTile` origins
199
+ - each tile wrapped in `MotionSurfaceOrigin`
200
+ - a demo dialog destination driven by `MotionSurface`
201
+ - tile-to-dialog morph and morph-back on close
202
+
203
+ Why:
204
+
205
+ - to test a real tile-to-dialog morph with Carbon components
206
+ - to keep the demo focused on `MotionSurface` rather than full Modal integration
207
+
208
+ What it uses:
209
+
210
+ - `ClickableTile`
211
+ - `AspectRatio`
212
+ - `Grid` and `Column`
213
+ - `MotionSurfaceOrigin`
214
+ - `DemoDialog`
215
+
216
+ ### `packages/react/src/components/Motion/Invoke.stories.js`
217
+
218
+ This file is the Storybook proof of concept for the `invoke` surface.
219
+
220
+ What we added:
221
+
222
+ - `ButtonToDialog`: button trigger morphs into the dialog
223
+ - `TileToDialog`: tile trigger morphs into the dialog
224
+ - each trigger wrapped in `MotionSurfaceOrigin` with `surface="invoke"`
225
+
226
+ Why:
227
+
228
+ - to show the invoke shared-element intent (morph from the trigger)
229
+ - to compare button and tile origins with the same surface
230
+
231
+ What it uses:
232
+
233
+ - `Button`
234
+ - `ClickableTile`
235
+ - `MotionSurfaceOrigin`
236
+ - `DemoDialog`
237
+
238
+ ### `packages/react/src/components/Motion/DemoDialog.js`
239
+
240
+ This file is story-only dialog chrome for the surface demos.
241
+
242
+ What we added:
243
+
244
+ - modal overlay classes for the backdrop fade
245
+ - a `MotionSurface` container so Motion owns the morph
246
+ - basic dialog semantics and a close control
247
+
248
+ Why:
249
+
250
+ - threading a surface through the real `Modal` is the next integration step
251
+ - the demo stays minimal so the story reads as a `MotionSurface` example
252
+
253
+ What it uses:
254
+
255
+ - `MotionSurface`
256
+ - Carbon modal class names via `usePrefix()`
257
+ - `Button`
258
+
259
+ ### `packages/react/src/components/Motion/surfaces.stories.scss`
260
+
261
+ This file styles the Motion surface stories.
262
+
263
+ What we added:
264
+
265
+ - layout helpers for the demo grid and origins
266
+ - neutralization of stock modal container transitions so CSS does not fight
267
+ Motion
268
+ - hover emphasis for the expand tile story
269
+ - a larger destination size for the expand dialog
270
+
271
+ Why:
272
+
273
+ - to keep demo layout and chrome styling out of the surface definitions
274
+ - to avoid competing CSS animations during the morph
275
+
276
+ What it uses:
277
+
278
+ - `@carbon/styles/scss/motion`
279
+ - `@carbon/styles/scss/spacing`
280
+ - `@carbon/styles/scss/utilities/box-shadow`
281
+
282
+ ## Files modified
283
+
284
+ ### `packages/motion/src/tokens.ts`
285
+
286
+ What changed:
287
+
288
+ - added named duration support
289
+ - added easing resolution helpers
290
+ - kept the existing token source as the single source of truth
291
+
292
+ Why:
293
+
294
+ - JavaScript adapters need numeric timing and easing values
295
+
296
+ ### `packages/motion/index.scss`
297
+
298
+ What changed:
299
+
300
+ - added generated `$surfaces`
301
+ - added `surface()` Sass function
302
+ - added `surface()` Sass mixin for reveal surfaces (`@starting-style` entrance,
303
+ guarded by `prefers-reduced-motion: no-preference`)
304
+ - shared-element surfaces are rejected by the mixin (no CSS-only form)
305
+
306
+ Why:
307
+
308
+ - Sass needs access to the same surface data
309
+ - reveal surfaces can run in plain CSS; shared-element morphs need a JavaScript
310
+ engine
311
+
312
+ ### `packages/motion/__tests__/motion-test.js`
313
+
314
+ What changed:
315
+
316
+ - added tests for expand, invoke, disclosure, and contextual surfaces
317
+ - added Sass parity tests for expand and disclosure
318
+ - added error handling for unknown surfaces and for applying the shared-element
319
+ mixin in CSS
320
+
321
+ ### `packages/motion/__tests__/__snapshots__/motion-test.js.snap`
322
+
323
+ What changed:
324
+
325
+ - snapshot updated because the public motion API changed
326
+
327
+ ### `packages/motion/docs/surfaces.md`
328
+
329
+ What changed:
330
+
331
+ - rewritten to match the current surface catalog and React adapter
332
+ - documented reveal vs shared-element kinds, story demos, gaps, and limits
333
+
334
+ ### `packages/motion/package.json`
335
+
336
+ What changed:
337
+
338
+ - added the surface Sass generation step to the build
339
+ - added `@carbon/scss-generator`
340
+
341
+ ### `packages/react/package.json`
342
+
343
+ What changed:
344
+
345
+ - added `motion` as a dependency
346
+
347
+ Why:
348
+
349
+ - the React adapter uses `motion/react` for `layoutId`, `AnimatePresence`, and
350
+ reduced-motion detection
351
+
352
+ ### `yarn.lock`
353
+
354
+ What changed:
355
+
356
+ - lockfile updated for dependency changes
357
+
358
+ ## Important implementation notes
359
+
360
+ ### Surface kinds
361
+
362
+ Surfaces are either:
363
+
364
+ - `reveal`: one element animates between enter / exit styles. Works in CSS (Sass
365
+ mixin) and in Motion React.
366
+ - `shared-element`: one element morphs into another. Needs a JavaScript engine.
367
+ In React this is Motion `layoutId` pairing between `MotionSurfaceOrigin` and
368
+ `MotionSurface`.
369
+
370
+ `expand` and `invoke` are both shared-element. They share the same React morph
371
+ path today. They differ in tokens:
372
+
373
+ - `expand`: productive easing, plus optional enter / exit opacity and scale
374
+ keyframes layered on the morph
375
+ - `invoke`: expressive easing, `origin: 'trigger'` (carried through the
376
+ resolver; the React morph path does not branch on `origin` yet)
377
+
378
+ ### Demo dialog ownership
379
+
380
+ We did not rebuild the Carbon Modal for the spike demos.
381
+
382
+ `DemoDialog` reuses modal overlay classes for the backdrop and puts
383
+ `MotionSurface` on the container. The real `Modal` still owns production dialog
384
+ behavior:
385
+
386
+ - dialog semantics
387
+ - overlay behavior
388
+ - focus trap
389
+ - close behavior
390
+ - submit and secondary actions
391
+
392
+ Wiring surfaces through the real `Modal` is the next integration step.
393
+
394
+ ### Why the React adapter uses Motion React `layoutId`
395
+
396
+ The current proof of concept uses declarative Motion React (`layoutId`,
397
+ `AnimatePresence`) instead of the earlier imperative `animate()` FLIP adapter.
398
+
399
+ Why:
400
+
401
+ - shared-element pairing maps cleanly to `MotionSurfaceOrigin` + `MotionSurface`
402
+ with a matching `surfaceId`
403
+ - reveal surfaces map cleanly to Motion `initial` / `animate` / `exit`
404
+ - Carbon presence can hold exit until Motion finishes
405
+
406
+ Tradeoff:
407
+
408
+ - layout projection can stretch in-origin content (for example tile text) during
409
+ the morph
410
+ - content crossfade / shell-only morph and axis-sequenced expand choreography
411
+ are still open follow-ups
412
+
413
+ ### Reduced motion
414
+
415
+ `prefers-reduced-motion` is intentionally not stored on the surface definition.
416
+ Framework adapters bail before running, and the Sass reveal mixin wraps
417
+ animation in `prefers-reduced-motion: no-preference`.
418
+
419
+ ## Current references
420
+
421
+ - Main architecture doc: `packages/motion/docs/surfaces.md`
422
+ - Surface definitions: `packages/motion/src/surfaces.ts`
423
+ - React surface components:
424
+ `packages/react/src/internal/motion/MotionSurface.tsx`
425
+ - Expand story: `packages/react/src/components/Motion/Expand.stories.js`
426
+ - Invoke story: `packages/react/src/components/Motion/Invoke.stories.js`
427
+ - Carbon Modal story: `packages/react/src/components/Modal/Modal.stories.js`
428
+ - Carbon Modal Sass: `packages/styles/scss/components/modal/_modal.scss`
429
+ - Carbon Tile Sass: `packages/styles/scss/components/tile/_tile.scss`
430
+
431
+ ## Current limits
432
+
433
+ - Shared-element React path does not yet differentiate `expand` and `invoke`
434
+ beyond easing and optional keyframes / `origin` metadata
435
+ - `origin: 'trigger'` is resolved but not used to change the morph
436
+ - Axis-sequenced expand (width then height) is not implemented
437
+ - Content stretch during `layoutId` morphs is unresolved (shell morph + content
438
+ crossfade is the likely follow-up)
439
+ - The proof of concept currently targets React only
440
+ - Story demos use `DemoDialog`, not the production `Modal` API
441
+ - Reveal surfaces (`disclosure`, `contextual`) are defined and resolved, but do
442
+ not yet have dedicated Storybook demos alongside expand / invoke
package/es/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/index.ts
1
+ //#region src/tokens.ts
2
2
  /**
3
3
  * Copyright IBM Corp. 2018, 2026
4
4
  *
@@ -31,18 +31,71 @@ const unstable_tokens = [
31
31
  "durationSlow01",
32
32
  "durationSlow02"
33
33
  ];
34
+ const durations = {
35
+ "fast-01": durationFast01,
36
+ "fast-02": durationFast02,
37
+ "moderate-01": durationModerate01,
38
+ "moderate-02": durationModerate02,
39
+ "slow-01": durationSlow01,
40
+ "slow-02": durationSlow02
41
+ };
42
+ const easingCurves = {
43
+ standard: {
44
+ productive: [
45
+ .2,
46
+ 0,
47
+ .38,
48
+ .9
49
+ ],
50
+ expressive: [
51
+ .4,
52
+ .14,
53
+ .3,
54
+ 1
55
+ ]
56
+ },
57
+ entrance: {
58
+ productive: [
59
+ 0,
60
+ 0,
61
+ .38,
62
+ .9
63
+ ],
64
+ expressive: [
65
+ 0,
66
+ 0,
67
+ .3,
68
+ 1
69
+ ]
70
+ },
71
+ exit: {
72
+ productive: [
73
+ .2,
74
+ 0,
75
+ 1,
76
+ .9
77
+ ],
78
+ expressive: [
79
+ .4,
80
+ .14,
81
+ 1,
82
+ 1
83
+ ]
84
+ }
85
+ };
86
+ const formatEasing = (curve) => `cubic-bezier(${curve.join(", ")})`;
34
87
  const easings = {
35
88
  standard: {
36
- productive: "cubic-bezier(0.2, 0, 0.38, 0.9)",
37
- expressive: "cubic-bezier(0.4, 0.14, 0.3, 1)"
89
+ productive: formatEasing(easingCurves.standard.productive),
90
+ expressive: formatEasing(easingCurves.standard.expressive)
38
91
  },
39
92
  entrance: {
40
- productive: "cubic-bezier(0, 0, 0.38, 0.9)",
41
- expressive: "cubic-bezier(0, 0, 0.3, 1)"
93
+ productive: formatEasing(easingCurves.entrance.productive),
94
+ expressive: formatEasing(easingCurves.entrance.expressive)
42
95
  },
43
96
  exit: {
44
- productive: "cubic-bezier(0.2, 0, 1, 0.9)",
45
- expressive: "cubic-bezier(0.4, 0.14, 1, 1)"
97
+ productive: formatEasing(easingCurves.exit.productive),
98
+ expressive: formatEasing(easingCurves.exit.expressive)
46
99
  }
47
100
  };
48
101
  const motion = (name, mode) => {
@@ -51,5 +104,96 @@ const motion = (name, mode) => {
51
104
  if (!easing[mode]) throw new Error(`Unable to find a mode for the easing \`${name}\` called: \`${mode}\`. Expected one of: ${Object.keys(easing).join(", ")}`);
52
105
  return easing[mode];
53
106
  };
107
+ const resolveEasing = (name, mode) => {
108
+ if (!easingCurves[name]) throw new Error(`Unable to find easing \`${name}\` in our supported easings. Expected one of: ${Object.keys(easingCurves).join(", ")}`);
109
+ const easing = easingCurves[name];
110
+ if (!easing[mode]) throw new Error(`Unable to find a mode for the easing \`${name}\` called: \`${mode}\`. Expected one of: ${Object.keys(easing).join(", ")}`);
111
+ return easing[mode];
112
+ };
113
+ const resolveDuration = (name) => {
114
+ const duration = durations[name];
115
+ if (!duration) throw new Error(`Unable to find duration \`${name}\` in our supported durations. Expected one of: ${Object.keys(durations).join(", ")}`);
116
+ return duration;
117
+ };
118
+ //#endregion
119
+ //#region src/surfaces.ts
120
+ /**
121
+ * Named motion intents. These definitions are engine and framework agnostic.
122
+ *
123
+ * `prefers-reduced-motion` is intentionally not represented here: surfaces
124
+ * never animate when the users request reduced motion. Framework adapters
125
+ * bail before running, and the Sass output is wrapped in a
126
+ * `prefers-reduced-motion: no-preference` media query
127
+ */
128
+ const surfaces = {
129
+ disclosure: {
130
+ kind: "reveal",
131
+ duration: "moderate-01",
132
+ enter: {
133
+ blockSize: "auto",
134
+ opacity: 1
135
+ },
136
+ exit: {
137
+ blockSize: 0,
138
+ opacity: 0
139
+ },
140
+ enterEasing: ["entrance", "productive"],
141
+ exitEasing: ["exit", "productive"]
142
+ },
143
+ contextual: {
144
+ kind: "reveal",
145
+ duration: "fast-02",
146
+ enter: {
147
+ opacity: 1,
148
+ transform: "scale(1)"
149
+ },
150
+ exit: {
151
+ opacity: 0,
152
+ transform: "scale(0.96)"
153
+ },
154
+ enterEasing: ["entrance", "expressive"],
155
+ exitEasing: ["exit", "expressive"]
156
+ },
157
+ stretch: {
158
+ kind: "reveal",
159
+ duration: "slow-01",
160
+ enter: {
161
+ opacity: 1,
162
+ clipPath: "inset(0 0 0 0)"
163
+ },
164
+ exit: {
165
+ opacity: 0,
166
+ clipPath: "inset(50% 0 50% 0)"
167
+ },
168
+ enterEasing: ["entrance", "expressive"],
169
+ exitEasing: ["exit", "expressive"]
170
+ },
171
+ expand: {
172
+ kind: "shared-element",
173
+ duration: "moderate-02",
174
+ enter: {
175
+ opacity: 1,
176
+ transform: "scale(1)"
177
+ },
178
+ exit: {
179
+ opacity: 0,
180
+ transform: "scale(0.96)"
181
+ },
182
+ enterEasing: ["standard", "productive"],
183
+ exitEasing: ["standard", "productive"]
184
+ },
185
+ invoke: {
186
+ kind: "shared-element",
187
+ origin: "trigger",
188
+ duration: "moderate-02",
189
+ enterEasing: ["standard", "expressive"],
190
+ exitEasing: ["standard", "expressive"]
191
+ }
192
+ };
193
+ const getMotionSurface = (name) => {
194
+ const surface = surfaces[name];
195
+ if (!surface) throw new Error(`Unable to find motion surface \`${name}\`. Expected one of: ` + Object.keys(surfaces).join(", "));
196
+ return surface;
197
+ };
54
198
  //#endregion
55
- export { durationFast01, durationFast02, durationModerate01, durationModerate02, durationSlow01, durationSlow02, easings, fast01, fast02, moderate01, moderate02, motion, slow01, slow02, unstable_tokens };
199
+ export { durationFast01, durationFast02, durationModerate01, durationModerate02, durationSlow01, durationSlow02, easings, fast01, fast02, getMotionSurface, moderate01, moderate02, motion, resolveDuration, resolveEasing, slow01, slow02, surfaces, unstable_tokens };