@tobiasoberpaul/mdk 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -23,7 +23,9 @@ toggle.addEventListener('click', () => motion.toggle());
23
23
 
24
24
  Your component manages layout, visibility, focus and accessibility. MDK handles the visual transition, including reversal and the system's reduced-motion preference.
25
25
 
26
- The library includes Grow, Extract, Morph, Slide and Content transitions. See the [library guide](https://github.com/TOberpaul/animations/blob/main/docs/LIBRARY.md) for the API, CSS tokens and integration patterns.
26
+ For an existing popover or menu, use `createPresenceMotion` to connect its open and close signals. Use `resize: 'transform'` with `createGrowTransition` for local growth on a surface positioned by the host. [Integration guide](https://github.com/TOberpaul/animations/blob/main/docs/LIBRARY.md#animate-an-existing-component)
27
+
28
+ The library includes Grow, Extract, Morph, Slide and Fade. See the [library guide](https://github.com/TOberpaul/animations/blob/main/docs/LIBRARY.md) for the API, CSS tokens and integration patterns.
27
29
 
28
30
  Coding assistants can access the MDK reference and integration examples through the public MCP server. See the [MCP setup](https://github.com/TOberpaul/animations/blob/main/docs/AI_INTEGRATION.md#hosted-mcp).
29
31
 
package/dist/behaviors.js CHANGED
@@ -7,13 +7,11 @@ export const motionBehaviors = {
7
7
  };
8
8
  /** Canonical naming shared by declarative integration and consuming components. */
9
9
  export function getMotionKind(content) {
10
- const names = [...Object.keys(motionBehaviors), 'liquid', 'content'];
10
+ const names = [...Object.keys(motionBehaviors), 'content'];
11
11
  const classes = names.filter(name => content.classList.contains(`motion-${name}`));
12
12
  const raw = content.dataset.motion ?? (classes.length === 1 ? classes[0] : undefined);
13
13
  if (raw === 'content')
14
14
  return 'fade';
15
- if (raw === 'liquid')
16
- return content.hasAttribute('data-motion-replace-trigger') ? 'morph' : 'extract';
17
15
  if (!raw || !Object.prototype.hasOwnProperty.call(motionBehaviors, raw))
18
16
  throw new Error('Set data-motion to grow, extract, morph, slide or fade, or use one matching motion-* class.');
19
17
  return raw;
@@ -52,7 +52,7 @@ export function createTransition(options, pattern, tracks, behavior = {}) {
52
52
  }
53
53
  const requestedEasing = token(content, `easing-${pattern}`);
54
54
  const easing = CSS.supports('animation-timing-function', requestedEasing) ? requestedEasing : token(content, 'easing-standard');
55
- const recipe = motionBehaviors[(pattern === 'scale' ? 'grow' : pattern === 'liquid' ? 'extract' : pattern === 'content' ? 'fade' : pattern)];
55
+ const recipe = motionBehaviors[(pattern === 'scale' ? 'grow' : pattern === 'transfer' ? 'extract' : pattern === 'content' ? 'fade' : pattern)];
56
56
  const descriptions = tracks();
57
57
  time *= behavior.durationScale?.() ?? 1;
58
58
  const reveal = options.reveal ? (Array.isArray(options.reveal) ? options.reveal : [options.reveal]) : [];
@@ -11,4 +11,4 @@ export interface ResizeOptions extends Options {
11
11
  overlay?: SVGSVGElement;
12
12
  }
13
13
  /** Shared physical resizing with a trigger edge or local origin. */
14
- export declare function createResizeTransition(options: ResizeOptions, pattern: 'morph' | 'scale'): Transition;
14
+ export declare function createResizeTransition(options: ResizeOptions, pattern: 'morph' | 'scale', transform?: boolean): Transition;
@@ -1,10 +1,12 @@
1
1
  import { createTransition } from './animation.js';
2
2
  import { growthAnchor, growthInsets, localScale, morphGeometry, resizeGeometry } from './geometry.js';
3
- import { overshootToken, numberToken, token } from './tokens.js';
3
+ import { lengthToken, overshootToken, numberToken, token } from './tokens.js';
4
4
  import { fixedContentTracks } from './content-layout.js';
5
5
  /** Shared physical resizing with a trigger edge or local origin. */
6
- export function createResizeTransition(options, pattern) {
6
+ export function createResizeTransition(options, pattern, transform = false) {
7
7
  const { content, trigger, overlay } = options;
8
+ if (transform && overlay)
9
+ throw new Error('Transform Grow does not support an SVG surface overlay.');
8
10
  const sameElement = trigger === content;
9
11
  if (sameElement && options.initiallyOpen)
10
12
  throw new Error('A same-element morph must initialize in its closed layout.');
@@ -32,6 +34,8 @@ export function createResizeTransition(options, pattern) {
32
34
  }
33
35
  options.onStateChange?.(state);
34
36
  } }, pattern, () => {
37
+ if (transform)
38
+ return transformGrowTracks(options);
35
39
  const edgeGeometry = !trigger || sameElement ? undefined : morphGeometry(trigger, content);
36
40
  const factor = trigger ? undefined : numberToken(content, 'scale-from');
37
41
  const collapseAxis = !trigger ? factor === 0 ? options.axis ?? 'both' : undefined : sameElement ? undefined : edgeGeometry.from.height === 0 ? 'height' : 'width';
@@ -39,7 +43,7 @@ export function createResizeTransition(options, pattern) {
39
43
  left: to.left + to.width * (options.axis === 'height' ? 0 : 1 - factor) * ax, top: to.top + to.height * (options.axis === 'width' ? 0 : 1 - factor) * ay,
40
44
  width: to.width * (options.axis === 'height' ? 1 : factor), height: to.height * (options.axis === 'width' ? 1 : factor)
41
45
  });
42
- const g = resizeGeometry(content, source, trigger ? edgeGeometry?.origin : token(content, 'scale-origin'), collapseAxis);
46
+ const g = resizeGeometry(content, source, trigger ? edgeGeometry?.origin : scaleOrigin(content), collapseAxis);
43
47
  const style = getComputedStyle(content);
44
48
  const base = style.transform;
45
49
  const shadow = style.boxShadow;
@@ -125,12 +129,103 @@ export function createResizeTransition(options, pattern) {
125
129
  result.push({ element: overlay, easing: 'linear', frames: frames.map(frame => ({
126
130
  ...frame, visibility: 'visible'
127
131
  })) });
128
- if (options.backdrop) {
129
- const native = options.backdrop === 'native';
130
- const element = native ? content : options.backdrop;
131
- const pseudoElement = native ? '::backdrop' : undefined;
132
- result.push({ element, pseudoElement, easing, frames: [{ opacity: 0 }, { opacity: getComputedStyle(element, pseudoElement).opacity }] });
133
- }
132
+ addBackdropTrack(result, options, easing);
134
133
  return result;
135
134
  }, { keepVisibleWhenClosed: sameElement });
136
135
  }
136
+ function addBackdropTrack(tracks, { content, backdrop }, easing) {
137
+ if (!backdrop)
138
+ return;
139
+ const element = backdrop === 'native' ? content : backdrop;
140
+ const pseudoElement = backdrop === 'native' ? '::backdrop' : undefined;
141
+ tracks.push({ element, pseudoElement, easing, frames: [{ opacity: 0 }, { opacity: getComputedStyle(element, pseudoElement).opacity }] });
142
+ }
143
+ function scaleOrigin(content) {
144
+ const origin = token(content, 'scale-origin');
145
+ if (!/\b(?:inline|block)-(?:start|end)\b/.test(origin))
146
+ return origin;
147
+ const { direction, writingMode } = getComputedStyle(content);
148
+ const horizontal = writingMode === 'horizontal-tb';
149
+ const inline = horizontal ? ['left', 'right'] : writingMode === 'sideways-lr' ? ['bottom', 'top'] : ['top', 'bottom'];
150
+ if (direction === 'rtl')
151
+ inline.reverse();
152
+ const block = horizontal ? ['top', 'bottom'] : writingMode.endsWith('-rl') ? ['right', 'left'] : ['left', 'right'];
153
+ return origin.replace(/\b(inline|block)-(start|end)\b/g, (_, axis, edge) => (axis === 'inline' ? inline : block)[edge === 'start' ? 0 : 1]);
154
+ }
155
+ function measureScaleOrigin(content, style, origin, width, height) {
156
+ const probe = content.ownerDocument.createElement('span');
157
+ probe.style.cssText = 'all:initial;position:fixed;visibility:hidden;pointer-events:none;transition:none!important;animation:none!important';
158
+ for (const name of ['font-size', 'font-family', 'line-height']) {
159
+ probe.style.setProperty(name, style.getPropertyValue(name));
160
+ }
161
+ const contentBox = style.transformBox === 'content-box' || style.transformBox === 'fill-box';
162
+ const inset = (side) => contentBox
163
+ ? parseFloat(style.getPropertyValue('padding-' + side)) + parseFloat(style.getPropertyValue('border-' + side + '-width'))
164
+ : 0;
165
+ const left = inset('left'), top = inset('top');
166
+ probe.style.width = `${Math.max(0, width - left - inset('right'))}px`;
167
+ probe.style.height = `${Math.max(0, height - top - inset('bottom'))}px`;
168
+ const read = () => getComputedStyle(probe).transformOrigin.split(' ').map(parseFloat);
169
+ let hostOrigin = style.transformOrigin.split(' ').map(parseFloat);
170
+ const parent = contentBox ? content : content.ownerDocument.body;
171
+ // Resolve keywords and percentages without starting a CSS transition on the host surface.
172
+ parent.appendChild(probe);
173
+ try {
174
+ // Resolved host styles can report percentages in border-box pixels even
175
+ // when transforms use the content box. Inherit onto the sized probe.
176
+ if (contentBox) {
177
+ probe.style.setProperty('transform-origin', 'inherit', 'important');
178
+ hostOrigin = read();
179
+ }
180
+ probe.style.setProperty('transform-origin', origin, 'important');
181
+ const [x, y] = read();
182
+ return { x: x + left, y: y + top, dx: x - hostOrigin[0], dy: y - hostOrigin[1] };
183
+ }
184
+ finally {
185
+ probe.remove();
186
+ }
187
+ }
188
+ function transformGrowTracks(options) {
189
+ const { content, axis } = options;
190
+ const style = getComputedStyle(content);
191
+ const opacity = style.opacity;
192
+ const origin = scaleOrigin(content);
193
+ if (!CSS.supports('transform-origin', origin))
194
+ throw new Error('Invalid --motion-scale-origin.');
195
+ const { width, height } = localScale(content, content.getBoundingClientRect());
196
+ if (!width || !height)
197
+ throw new Error('Grow content must be measurable before animation.');
198
+ const { x, y, dx, dy } = measureScaleOrigin(content, style, origin, width, height);
199
+ const end = numberToken(content, 'scale-geometry-end');
200
+ if (end <= 0 || end > 1)
201
+ throw new Error('Scale geometry end must satisfy 0 < end <= 1.');
202
+ const requested = token(content, 'easing-scale');
203
+ const easing = CSS.supports('animation-timing-function', requested) ? requested : token(content, 'easing-standard');
204
+ const from = numberToken(content, 'scale-from');
205
+ const frame = (offset, sx, sy) => ({
206
+ offset, easing,
207
+ // Append a local scale around the motion origin while preserving the host origin.
208
+ transform: `translate(${dx * (1 - sx)}px, ${dy * (1 - sy)}px) scale(${sx}, ${sy})`,
209
+ });
210
+ const frames = [frame(0, axis === 'height' ? 1 : from, axis === 'width' ? 1 : from)];
211
+ const overshoot = Math.min(overshootToken(content, 'scale-overshoot', width, height), lengthToken(content, 'overshoot-max'));
212
+ if (overshoot > 0) {
213
+ const peak = numberToken(content, 'scale-peak-offset');
214
+ if (peak <= 0 || peak >= end)
215
+ throw new Error('Scale peak must satisfy 0 < peak < geometry end.');
216
+ frames.push(frame(peak, axis === 'height' ? 1 : 1 + overshoot / Math.max(Math.abs(x), Math.abs(width - x)), axis === 'width' ? 1 : 1 + overshoot / Math.max(Math.abs(y), Math.abs(height - y))));
217
+ }
218
+ frames.push(frame(end, 1, 1));
219
+ if (end < 1)
220
+ frames.push(frame(1, 1, 1));
221
+ const tracks = [
222
+ { element: content, frames, easing: 'linear', composite: 'add' },
223
+ { element: content, easing: 'linear', frames: [
224
+ { offset: 0, easing, opacity: numberToken(content, 'scale-opacity') },
225
+ { offset: end, opacity },
226
+ ...(end < 1 ? [{ offset: 1, opacity }] : []),
227
+ ] },
228
+ ];
229
+ addBackdropTrack(tracks, options, easing);
230
+ return tracks;
231
+ }
@@ -45,9 +45,9 @@ export function lengthToken(element, name) {
45
45
  }
46
46
  /** Proportional overshoot per edge, capped by the pattern's CSS length budget. */
47
47
  export function overshootToken(element, name, width, height) {
48
- const percentage = token(element, 'overshoot-min');
48
+ const percentage = getComputedStyle(element).getPropertyValue('--motion-overshoot-ratio').trim() || token(element, 'overshoot-min');
49
49
  if (!/^\d+(?:\.\d+)?%$/.test(percentage))
50
- throw new Error('--motion-overshoot-min must be a non-negative percentage.');
50
+ throw new Error('--motion-overshoot-ratio must be a non-negative percentage.');
51
51
  const style = getComputedStyle(element);
52
52
  const w = width ?? parseFloat(style.width), h = height ?? parseFloat(style.height);
53
53
  return Math.min(Math.min(w, h) * parseFloat(percentage) / 100, lengthToken(element, name));
package/dist/index.d.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  export { createMorphTransition } from './transitions/morph.js';
2
- export { createLiquidMorphTransition } from './transitions/liquid.js';
3
- export type { LiquidMorphOptions, LiquidMorphTransition } from './transitions/liquid.js';
4
2
  export type { MorphOptions } from './transitions/morph.js';
5
3
  export { createSlideTransition } from './transitions/slide.js';
6
4
  export type { SlideOptions } from './transitions/slide.js';
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export { createMorphTransition } from './transitions/morph.js';
2
- export { createLiquidMorphTransition } from './transitions/liquid.js';
3
2
  export { createSlideTransition } from './transitions/slide.js';
4
3
  export { createScaleTransition } from './transitions/scale.js';
5
4
  export { createContentTransition } from './transitions/content.js';
package/dist/layout.js CHANGED
@@ -10,6 +10,8 @@ export function captureMotionLayout(element) {
10
10
  /** Animate a component-owned CSS layout change from its captured visual state. */
11
11
  export function animateMotionLayout(element, from) {
12
12
  active.get(element)?.cancel();
13
+ if (typeof element.animate !== 'function')
14
+ return () => { };
13
15
  const to = captureMotionLayout(element);
14
16
  if (element.ownerDocument.defaultView.matchMedia('(prefers-reduced-motion: reduce)').matches || duration(element, 'morph') === 0)
15
17
  return () => { };
@@ -5,6 +5,8 @@ export interface PresenceMotionOptions extends Pick<Options, 'initiallyOpen'> {
5
5
  /** Supply only if the integration can synchronously show and hide the surface.
6
6
  * Without this callback, call setOpen(true) after the host has shown the surface. */
7
7
  setPresent?(present: boolean): void;
8
+ /** Reflect data-motion-state on this surface. Its CSS must keep the surface visible during closing. */
9
+ stateTarget?: HTMLElement;
8
10
  }
9
11
  export interface PresenceMotion {
10
12
  readonly state: MotionState;
package/dist/presence.js CHANGED
@@ -1,19 +1,25 @@
1
1
  /** Drive MDK from a component's existing open/close signals. */
2
2
  export function createPresenceMotion(options) {
3
- const { createTransition, setPresent } = options;
3
+ const { createTransition, setPresent, stateTarget } = options;
4
+ const stateAttribute = 'data-motion-state';
5
+ const originalState = stateTarget?.getAttribute(stateAttribute);
4
6
  let open = options.initiallyOpen ?? false;
5
7
  let destroyed = false;
6
8
  let controller;
7
- if (setPresent) {
9
+ if (setPresent || stateTarget) {
8
10
  controller = createTransition({
9
11
  initiallyOpen: open,
10
12
  onStateChange(state) {
13
+ if (state === 'destroyed')
14
+ return;
15
+ stateTarget?.setAttribute(stateAttribute, state);
11
16
  if (state === 'opening')
12
- setPresent(true);
17
+ setPresent?.(true);
13
18
  if (state === 'closed')
14
- setPresent(false);
19
+ setPresent?.(false);
15
20
  }
16
21
  });
22
+ stateTarget?.setAttribute(stateAttribute, controller.state);
17
23
  }
18
24
  return {
19
25
  get state() {
@@ -29,7 +35,7 @@ export function createPresenceMotion(options) {
29
35
  controller ?? (controller = createTransition({ initiallyOpen: false }));
30
36
  controller.open();
31
37
  }
32
- else if (setPresent) {
38
+ else if (setPresent || stateTarget) {
33
39
  controller?.close();
34
40
  }
35
41
  else {
@@ -44,6 +50,10 @@ export function createPresenceMotion(options) {
44
50
  destroyed = true;
45
51
  const pendingExit = controller?.state === 'closing';
46
52
  controller?.destroy();
53
+ if (originalState == null)
54
+ stateTarget?.removeAttribute(stateAttribute);
55
+ else
56
+ stateTarget?.setAttribute(stateAttribute, originalState);
47
57
  if (pendingExit)
48
58
  setPresent?.(false);
49
59
  }
@@ -58,19 +58,19 @@
58
58
  --motion-scale-origin: center center;
59
59
  --motion-slide-distance: 100%;
60
60
  --motion-slide-peak-offset: .8;
61
- --motion-duration-liquid: var(--motion-duration);
62
- --motion-easing-liquid: var(--motion-easing-standard);
63
- --motion-liquid-geometry-end: .8;
64
- --motion-liquid-reveal-offset: .3;
65
- --motion-liquid-reveal-end: var(--motion-liquid-geometry-end);
66
- --motion-liquid-reveal-blur: var(--motion-reveal-blur);
67
- --motion-liquid-volume-offset: .18;
68
- --motion-liquid-source-hide-start: var(--motion-liquid-reveal-offset);
69
- --motion-liquid-source-hide-end: var(--motion-liquid-reveal-end);
70
- --motion-liquid-source-blur: var(--motion-liquid-reveal-blur);
71
- --motion-liquid-peak-offset: .5;
61
+ --motion-duration-transfer: var(--motion-duration);
62
+ --motion-easing-transfer: var(--motion-easing-standard);
63
+ --motion-transfer-geometry-end: .8;
64
+ --motion-transfer-reveal-offset: .3;
65
+ --motion-transfer-reveal-end: var(--motion-transfer-geometry-end);
66
+ --motion-transfer-reveal-blur: var(--motion-reveal-blur);
67
+ --motion-transfer-volume-offset: .18;
68
+ --motion-transfer-source-hide-start: var(--motion-transfer-reveal-offset);
69
+ --motion-transfer-source-hide-end: var(--motion-transfer-reveal-end);
70
+ --motion-transfer-source-blur: var(--motion-transfer-reveal-blur);
71
+ --motion-transfer-peak-offset: .5;
72
72
  --motion-overshoot-max: .75rem;
73
73
  }
74
74
  @media (prefers-reduced-motion: reduce) {
75
- :root { --motion-duration-morph: 0ms; --motion-duration-slide: 0ms; --motion-duration-scale: 0ms; --motion-duration-backdrop: 0ms; }
75
+ :root { --motion-duration-morph: 0ms; --motion-duration-slide: 0ms; --motion-duration-scale: 0ms; --motion-duration-backdrop: 0ms; --motion-duration-content: 0ms; --motion-duration-transfer: 0ms; }
76
76
  }
@@ -1,5 +1,5 @@
1
- import { type LiquidMorphOptions } from './liquid.js';
1
+ import { type SurfaceTransferOptions } from './surface-transfer.js';
2
2
  import type { Transition } from '../core/lifecycle.js';
3
- export type ExtractOptions = Omit<LiquidMorphOptions, 'replaceTrigger'>;
3
+ export type ExtractOptions = Omit<SurfaceTransferOptions, 'replaceTrigger'>;
4
4
  /** Extract a new surface while retaining the original trigger. */
5
5
  export declare function createExtractTransition(options: ExtractOptions): Transition;
@@ -1,5 +1,5 @@
1
- import { createLiquidMorphTransition } from './liquid.js';
1
+ import { createSurfaceTransfer } from './surface-transfer.js';
2
2
  /** Extract a new surface while retaining the original trigger. */
3
3
  export function createExtractTransition(options) {
4
- return createLiquidMorphTransition({ ...options, replaceTrigger: false });
4
+ return createSurfaceTransfer({ ...options, replaceTrigger: false });
5
5
  }
@@ -1,6 +1,8 @@
1
1
  import { type ResizeOptions } from '../core/resize.js';
2
2
  import type { Transition } from '../core/lifecycle.js';
3
3
  export interface GrowOptions extends ResizeOptions {
4
+ /** Use visual scaling for local Grow. Trigger-edge Grow always uses physical resizing. */
5
+ resize?: 'metrics' | 'transform';
4
6
  }
5
7
  /** Grow from a trigger edge, or from the configured local origin without a trigger. */
6
8
  export declare function createGrowTransition(options: GrowOptions): Transition;
@@ -1,5 +1,5 @@
1
1
  import { createResizeTransition } from '../core/resize.js';
2
2
  /** Grow from a trigger edge, or from the configured local origin without a trigger. */
3
3
  export function createGrowTransition(options) {
4
- return createResizeTransition(options, options.trigger ? 'morph' : 'scale');
4
+ return createResizeTransition(options, options.trigger ? 'morph' : 'scale', !options.trigger && options.resize === 'transform');
5
5
  }
@@ -1,5 +1,5 @@
1
- import { type LiquidMorphOptions } from './liquid.js';
1
+ import { type SurfaceTransferOptions } from './surface-transfer.js';
2
2
  import type { Transition } from '../core/lifecycle.js';
3
- export type MorphOptions = Omit<LiquidMorphOptions, 'replaceTrigger'>;
3
+ export type MorphOptions = Omit<SurfaceTransferOptions, 'replaceTrigger'>;
4
4
  /** The source becomes the target; the integration manages original-trigger visibility. */
5
5
  export declare function createMorphTransition(options: MorphOptions): Transition;
@@ -1,5 +1,5 @@
1
- import { createLiquidMorphTransition } from './liquid.js';
1
+ import { createSurfaceTransfer } from './surface-transfer.js';
2
2
  /** The source becomes the target; the integration manages original-trigger visibility. */
3
3
  export function createMorphTransition(options) {
4
- return createLiquidMorphTransition({ ...options, replaceTrigger: true });
4
+ return createSurfaceTransfer({ ...options, replaceTrigger: true });
5
5
  }
@@ -3,5 +3,5 @@ export interface ScaleOptions extends Options {
3
3
  backdrop?: HTMLElement | 'native';
4
4
  axis?: 'width' | 'height';
5
5
  }
6
- /** The same resize behavior, starting at the component's configured origin. */
6
+ /** @deprecated Use createGrowTransition for local growth. */
7
7
  export declare function createScaleTransition(options: ScaleOptions): Transition;
@@ -1,5 +1,5 @@
1
1
  import { createGrowTransition } from './grow.js';
2
- /** The same resize behavior, starting at the component's configured origin. */
2
+ /** @deprecated Use createGrowTransition for local growth. */
3
3
  export function createScaleTransition(options) {
4
4
  return createGrowTransition(options);
5
5
  }
@@ -1,5 +1,5 @@
1
1
  import type { Options, Transition } from '../core/lifecycle.js';
2
- export interface LiquidMorphOptions extends Options {
2
+ export interface SurfaceTransferOptions extends Options {
3
3
  /** Distinct node: retain the source. Same node as content: become the target.
4
4
  * For the same node, prepare the expanded layout synchronously on opening
5
5
  * and restore its compact layout only on closed. Initialize closed. */
@@ -8,6 +8,5 @@ export interface LiquidMorphOptions extends Options {
8
8
  * trigger. The component hides/shows that trigger in onStateChange. */
9
9
  replaceTrigger?: boolean;
10
10
  }
11
- export type LiquidMorphTransition = Transition;
12
- /** One liquid behavior for separate surfaces and a trigger becoming the target. */
13
- export declare function createLiquidMorphTransition(options: LiquidMorphOptions): LiquidMorphTransition;
11
+ /** Shared surface and foreground animation for Extract and Morph. */
12
+ export declare function createSurfaceTransfer(options: SurfaceTransferOptions): Transition;
@@ -38,12 +38,12 @@ function visibleBackground(element) {
38
38
  const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
39
39
  return `rgb(${r},${g},${b})`;
40
40
  }
41
- /** One liquid behavior for separate surfaces and a trigger becoming the target. */
42
- export function createLiquidMorphTransition(options) {
41
+ /** Shared surface and foreground animation for Extract and Morph. */
42
+ export function createSurfaceTransfer(options) {
43
43
  const sameElement = options.trigger === options.content;
44
44
  const replacesTrigger = sameElement || options.replaceTrigger;
45
45
  if (replacesTrigger && options.initiallyOpen)
46
- throw new Error('A replacing liquid morph must initialize in its closed layout.');
46
+ throw new Error('A replacing transfer morph must initialize in its closed layout.');
47
47
  let previous = options.initiallyOpen ? 'open' : 'closed';
48
48
  let closed;
49
49
  let overlay;
@@ -78,7 +78,7 @@ export function createLiquidMorphTransition(options) {
78
78
  remove();
79
79
  }
80
80
  options.onStateChange?.(state);
81
- } }, 'liquid', () => {
81
+ } }, 'transfer', () => {
82
82
  remove();
83
83
  const { content, trigger } = options;
84
84
  const box = content.getBoundingClientRect(), source = replacesTrigger ? closed.rect : trigger.getBoundingClientRect();
@@ -88,7 +88,7 @@ export function createLiquidMorphTransition(options) {
88
88
  const width = px(style.width) + (style.boxSizing === 'border-box' ? 0 : extraX);
89
89
  const height = px(style.height) + (style.boxSizing === 'border-box' ? 0 : extraY);
90
90
  if (!width || !height || !source.width || !source.height)
91
- throw new Error('Liquid morph elements must be measurable.');
91
+ throw new Error('Surface transfer elements must be measurable.');
92
92
  const scaleX = box.width / width, scaleY = box.height / height;
93
93
  const left = (source.left - box.left) / scaleX, top = (source.top - box.top) / scaleY;
94
94
  const sw = source.width / scaleX, sh = source.height / scaleY;
@@ -102,10 +102,10 @@ export function createLiquidMorphTransition(options) {
102
102
  const borderFrom = replacesTrigger ? closed.border : triggerStyle.borderTopColor, borderTo = style.borderTopColor;
103
103
  const swell = overshootToken(content, 'overshoot-max', sw, sh);
104
104
  const overshoot = overshootToken(content, 'overshoot-max', width, height);
105
- const volume = numberToken(content, 'liquid-volume-offset'), peak = numberToken(content, 'liquid-peak-offset');
106
- const end = numberToken(content, 'liquid-geometry-end');
105
+ const volume = numberToken(content, 'transfer-volume-offset'), peak = numberToken(content, 'transfer-peak-offset');
106
+ const end = numberToken(content, 'transfer-geometry-end');
107
107
  if (!(swell >= 0 && overshoot >= 0 && volume > 0 && volume < peak && peak < end && end < 1))
108
- throw new Error('Invalid liquid geometry tokens.');
108
+ throw new Error('Invalid transfer geometry tokens.');
109
109
  // The initial volume phase swells the trigger symmetrically. Only the target
110
110
  // grows away from its component-owned anchor; preserve their envelope.
111
111
  const anchor = explicitMotionAnchor(content) ?? growthAnchor(source, box);
@@ -140,9 +140,9 @@ export function createLiquidMorphTransition(options) {
140
140
  triggerCopy = replacesTrigger ? closed.copy.cloneNode(true) : copyTrigger(trigger);
141
141
  Object.assign(triggerCopy.style, { position: 'absolute', left: (left - px(style.borderLeftWidth)) + 'px', top: (top - px(style.borderTopWidth)) + 'px', width: sw + 'px', height: sh + 'px', boxSizing: 'border-box', margin: '0', transform: 'none', transition: 'none', animation: 'none', background: 'transparent', borderColor: 'transparent', boxShadow: 'none', pointerEvents: 'none', zIndex: '0' });
142
142
  content.append(triggerCopy);
143
- const revealStart = numberToken(content, 'liquid-reveal-offset'), revealEnd = numberToken(content, 'liquid-reveal-end');
143
+ const revealStart = numberToken(content, 'transfer-reveal-offset'), revealEnd = numberToken(content, 'transfer-reveal-end');
144
144
  if (revealStart < 0 || revealStart >= revealEnd || revealEnd > 1)
145
- throw new Error('Invalid liquid reveal offsets.');
145
+ throw new Error('Invalid transfer reveal offsets.');
146
146
  const clipFrames = [];
147
147
  // Include the exact volume/peak poses, so frame inspection
148
148
  // reach the configured percentage without resampling error.
@@ -186,13 +186,13 @@ export function createLiquidMorphTransition(options) {
186
186
  ];
187
187
  const overlapsTarget = source.left < box.right && source.right > box.left && source.top < box.bottom && source.bottom > box.top;
188
188
  if (replacesTrigger || overlapsTarget) {
189
- const hideStart = numberToken(content, 'liquid-source-hide-start');
190
- const hideEnd = numberToken(content, 'liquid-source-hide-end');
191
- const blur = token(content, 'liquid-source-blur');
189
+ const hideStart = numberToken(content, 'transfer-source-hide-start');
190
+ const hideEnd = numberToken(content, 'transfer-source-hide-end');
191
+ const blur = token(content, 'transfer-source-blur');
192
192
  if (hideStart < 0 || hideStart >= hideEnd || hideEnd > 1)
193
- throw new Error('Liquid source hide offsets must satisfy 0 <= start < end <= 1.');
193
+ throw new Error('Transfer source hide offsets must satisfy 0 <= start < end <= 1.');
194
194
  if (!CSS.supports('filter', `blur(${blur})`))
195
- throw new Error('Liquid source blur must be a valid non-negative CSS length.');
195
+ throw new Error('Transfer source blur must be a valid non-negative CSS length.');
196
196
  const sourceStyle = getComputedStyle(triggerCopy), filter = sourceStyle.filter;
197
197
  const blurred = `${filter === 'none' ? '' : `${filter} `}blur(${blur})`;
198
198
  // Crossfade both contents over the same interval, without moving the
package/docs/LIBRARY.md CHANGED
@@ -23,14 +23,14 @@ content, unless reflow is intentional (the three-column layout).
23
23
  The package name is `@tobiasoberpaul/mdk`.
24
24
 
25
25
  ```ts
26
- import { createScaleTransition } from '@tobiasoberpaul/mdk';
26
+ import { createGrowTransition } from '@tobiasoberpaul/mdk';
27
27
  import '@tobiasoberpaul/mdk/motion.css';
28
28
 
29
29
  const surface = document.querySelector<HTMLElement>('#dialog')!;
30
30
  const trigger = document.querySelector<HTMLButtonElement>('#open')!;
31
31
  surface.inert = true;
32
32
 
33
- const transition = createScaleTransition({
33
+ const transition = createGrowTransition({
34
34
  content: surface,
35
35
  reveal: [...surface.children].filter((node): node is HTMLElement => node instanceof HTMLElement),
36
36
  onStateChange(state) {
@@ -104,21 +104,53 @@ mechanism in this callback. The same surface must remain mounted through exit.
104
104
  Keep requested open state separate from visual presence so a new open request
105
105
  can reverse an in-progress exit.
106
106
 
107
- If the host hides itself immediately, omit `setPresent`. Call `setOpen(true)`
107
+ If the host can hold presence through CSS, pass `stateTarget: surface`. The
108
+ adapter reflects `opening`, `open`, `closing` and `closed` in
109
+ `data-motion-state` and restores any previous value on teardown. Let the
110
+ host's CSS keep the surface rendered at its normal open opacity during
111
+ `closing`, so the animation can measure and fade it.
112
+ Forward the host's requested state, independently of the held visibility,
113
+ so reopening can reverse the exit. Keep focus and interaction rules in the host.
114
+
115
+ If the host hides itself immediately, omit both `setPresent` and `stateTarget`. Call `setOpen(true)`
108
116
  after the host has made its surface visible and positioned it, then call
109
117
  `setOpen(false)` when it hides. This mode plays entry and releases the effect
110
118
  on close. It requires a reliable signal from the host integration. Disable any
111
119
  competing host animation on the same surface.
112
120
 
121
+ ## Grow on a host-positioned surface
122
+
123
+ For a popover positioned by its host, opt into transform-based local Grow:
124
+
125
+ ```ts
126
+ const motion = createGrowTransition({ content: surface, resize: 'transform' });
127
+ ```
128
+
129
+ Use `--motion-scale-origin: inline-end block-start` for logical placement in
130
+ either text direction or writing mode. Use `right top` for a physical anchor.
131
+ Omit `trigger` when the component owns positioning and you want local growth.
132
+ Pass `trigger` only to grow from the trigger's edge. Use
133
+ `--motion-scale-from` for its starting scale. MDK adds the animation to the
134
+ host transform and leaves layout dimensions and insets alone. The whole surface
135
+ scales visually, including its border and content. Set `--motion-overshoot-max: 0`
136
+ to turn off overshoot. This mode works with `createPresenceMotion`.
137
+ MDK resolves the motion origin without changing the surface's own
138
+ `transform-origin`, so host CSS transitions on that property stay independent.
139
+ The anchor follows the surface's `transform-box`, including `content-box`.
140
+ Origin and dimensions are measured at the start of each animation.
141
+
142
+ The default `resize: 'metrics'` still resizes the actual surface. Grow with a
143
+ `trigger` continues to use trigger-edge geometry. The local transform mode
144
+ supports HTML surfaces without an SVG overlay.
145
+
113
146
  ## Public behavior factories
114
147
 
115
148
  | Factory | Behavior | Required additions to shared options |
116
149
  | --- | --- | --- |
117
- | `createGrowTransition` | Grow from a configured origin or trigger edge | Optional `trigger`, `axis`, `backdrop` |
118
- | `createScaleTransition` | Origin-based Grow compatibility factory | Optional `axis`, `backdrop` |
150
+ | `createGrowTransition` | Grow from a configured origin or trigger edge | Optional `trigger`, `axis`, `backdrop`, `resize` |
151
+ | `createScaleTransition` | Deprecated alias for local Grow | Optional `axis`, `backdrop` |
119
152
  | `createExtractTransition` | Extract a new surface while retaining the trigger | `trigger` |
120
153
  | `createMorphTransition` | Transform the trigger into the target surface | `trigger` |
121
- | `createLiquidMorphTransition` | Source volume → encompassing surface → target | `trigger`; optional `replaceTrigger` |
122
154
  | `createSlideTransition` | Bounded translate3d entry or two-pane shift | Optional `side`, `mode`, `outgoing`, `backdrop` |
123
155
  | `createContentTransition` | Fixed slide + blur + fade | Optional `items`, `reveal`, `timing` |
124
156
  | `createMotion` | Selects a factory from markup | See declarative integration below |
@@ -132,7 +164,7 @@ opacity and blur. Explicit `items` for Content must not be nested.
132
164
 
133
165
  ### Grow
134
166
 
135
- `createScaleTransition` preserves the component's base transform and reads its
167
+ `createGrowTransition` preserves the component's base transform and reads its
136
168
  origin from `--motion-scale-origin`. The library default is `.92` for
137
169
  `--motion-scale-from`; the demos set `0` for fully collapsed opening.
138
170
 
@@ -158,16 +190,17 @@ Public factories, declarative markup and example titles use the same meanings:
158
190
 
159
191
  Migration: the earlier `createMorphTransition` name referred to trigger-edge Grow.
160
192
  Use `createGrowTransition` for that old behavior. `createMorphTransition` now
161
- means replacing Morph, consistent with the examples. The lower-level
162
- `createLiquidMorphTransition` remains available with an explicit
163
- `replaceTrigger` flag, and `createScaleTransition` remains an origin-Grow alias.
193
+ means replacing Morph, consistent with the examples. To migrate from the former
194
+ Liquid API or markup, use Extract when the source stays visible and Morph when
195
+ the source becomes the target. Use the `--motion-transfer-*` and
196
+ `--motion-duration-transfer` tokens for the shared animation settings.
164
197
 
165
198
  Extract retains the original trigger. Replacing Morph snapshots its foreground,
166
199
  then crossfades that foreground into target content while one material surface
167
200
  moves between the geometries. The integration hides/inerts the real trigger
168
201
  until closed and restores it afterward. This avoids nested interactive controls.
169
202
 
170
- Liquid animates a temporary HTML surface with CSS dimensions, corners, background and borders. It removes the temporary surface and foreground
203
+ Extract and Morph animate a temporary HTML surface with CSS dimensions, corners, background and borders. They remove the temporary surface and foreground
171
204
  copy at rest. The resting UI is the real HTML component. Trigger swelling is a
172
205
  phase of the click animation; there is no press/hold feature or press/release API.
173
206
 
@@ -178,7 +211,7 @@ the right edge. Both buttons remain visible and the main action is independent.
178
211
 
179
212
  ### Material source and composite inputs
180
213
 
181
- Liquid reads material values from `content`. If a component wrapper is
214
+ Extract and Morph read material values from `content`. If a component wrapper is
182
215
  transparent/borderless but a nested input paints the real material, explicitly
183
216
  prepare a surface with the input's background, border and radius before the
184
217
  motion snapshot. During travel, prevent the inner control from painting a second
@@ -234,12 +267,10 @@ const transition = createMotion({
234
267
  | `data-motion-axis` | Grow axis: `width` or `height` |
235
268
  | `data-motion-side` | Slide entry side |
236
269
  | `data-motion-timing` | Content: `per-item` (default) or `total` |
237
- | `data-motion-replace-trigger` | Presence enables replacing Liquid |
238
270
 
239
271
  An explicit `data-motion` takes precedence over classes. Without it, use exactly
240
272
  one matching class. Declarative `morph` selects the replacing Morph factory directly.
241
- The old `liquid` / `motion-liquid` spelling remains a compatibility path; its
242
- `data-motion-replace-trigger` flag retains its original meaning. Recreate the controller to change behavior after
273
+ Recreate the controller to change behavior after
243
274
  initialization. Change CSS tokens for per-trip visual parameters.
244
275
 
245
276
  ## Content progression and ordering
@@ -333,9 +364,9 @@ can be overridden locally on a surface or globally.
333
364
 
334
365
  | Token | Library default / meaning |
335
366
  | --- | --- |
336
- | `--motion-duration` | `300ms`; aliases for morph, scale, liquid, slide, content, backdrop |
367
+ | `--motion-duration` | `300ms`; aliases for morph, scale, transfer, slide, content, backdrop |
337
368
  | `--motion-easing-standard` | `cubic-bezier(.42, 0, .58, 1)`; symmetric open/close |
338
- | `--motion-overshoot-min` | `15%` of the surface's shorter side |
369
+ | `--motion-overshoot-ratio` | `15%` of the surface's shorter side by default. When unset, reads the legacy `--motion-overshoot-min` token. |
339
370
  | `--motion-overshoot-max` | `.75rem` cap shared by trigger and target; morph/scale alias this |
340
371
  | `--motion-content-progressive` | `1` (enabled by default); set to `0` to disable |
341
372
  | `--motion-content-stagger` | `.08`, fraction of base duration for Content |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tobiasoberpaul/mdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -14,7 +14,7 @@
14
14
  "./motion.css": "./dist/styles/motion.css"
15
15
  },
16
16
  "scripts": {
17
- "build": "tsc && node scripts/copy-css.mjs",
17
+ "build": "node scripts/clean-dist.mjs && tsc && node scripts/copy-css.mjs",
18
18
  "build:examples": "node scripts/build-db-react.mjs && node scripts/build-shadcn.mjs",
19
19
  "build:shadcn": "node scripts/build-shadcn.mjs",
20
20
  "analytics": "node context-starter/analytics/serve.mjs . --codex-history",