framer-motion
Version:
A simple and powerful JavaScript animation library
1,910 lines • 72.9 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var motionDom = require('motion-dom');
var motionUtils = require('motion-utils');
function isDOMKeyframes(keyframes) {
return typeof keyframes === "object" && !Array.isArray(keyframes);
}
function resolveSubjects(subject, keyframes, scope, selectorCache) {
if (subject == null) {
return [];
}
if (typeof subject === "string" && isDOMKeyframes(keyframes)) {
return motionDom.resolveElements(subject, scope, selectorCache);
}
else if (subject instanceof NodeList) {
return Array.from(subject);
}
else if (Array.isArray(subject)) {
return subject.filter((s) => s != null);
}
else {
return [subject];
}
}
function calculateRepeatDuration(duration, repeat, repeatDelay) {
return duration * (repeat + 1) + repeatDelay * repeat;
}
/**
* The time of a label, or a label with an offset like "label+1" or
* "label-0.5". An exact label name wins over an offset.
*/
function calcLabelTime(position, labels) {
if (labels.has(position))
return labels.get(position);
const [, name, offset] = position.match(/^(.+?)([+-][\d.]+)$/) || [];
return labels.has(name)
? Math.max(0, labels.get(name) + parseFloat(offset))
: undefined;
}
/**
* Given a absolute or relative time definition and current/prev time state of the sequence,
* calculate an absolute time for the next keyframes.
*/
function calcNextTime(current, next, prev, labels) {
if (typeof next === "number") {
return next;
}
else if (next.startsWith("-") || next.startsWith("+")) {
return Math.max(0, current + parseFloat(next));
}
else if (next === "<") {
return prev;
}
else if (next.startsWith("<")) {
return Math.max(0, prev + parseFloat(next.slice(1)));
}
else {
return calcLabelTime(next, labels) ?? current;
}
}
function eraseKeyframes(sequence, startTime, endTime) {
for (let i = 0; i < sequence.length; i++) {
const keyframe = sequence[i];
if (keyframe.at > startTime && keyframe.at < endTime) {
motionUtils.removeItem(sequence, keyframe);
// If we remove this item we have to push the pointer back one
i--;
}
}
}
function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) {
/**
* Erase every existing value between currentTime and targetTime,
* this will essentially splice this timeline into any currently
* defined ones.
*/
eraseKeyframes(sequence, startTime, endTime);
for (let i = 0; i < keyframes.length; i++) {
sequence.push({
value: keyframes[i],
at: motionDom.mixNumber(startTime, endTime, offset[i]),
easing: motionUtils.getEasingForSegment(easing, i),
});
}
}
/**
* Take an array of times that represent repeated keyframes. For instance
* if we have original times of [0, 0.5, 1] then our repeated times will
* be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back
* down to a 0-1 scale.
*
* `repeatDelayUnits` is the repeatDelay expressed in units of a single
* iteration's duration, so the total span equals `(repeat + 1) + repeat * repeatDelayUnits`.
*/
function normalizeTimes(times, repeat, repeatDelayUnits = 0) {
const totalUnits = repeat + 1 + repeat * repeatDelayUnits;
for (let i = 0; i < times.length; i++) {
times[i] = times[i] / totalUnits;
}
}
function compareByTime(a, b) {
if (a.at === b.at) {
if (a.value === null)
return 1;
if (b.value === null)
return -1;
return 0;
}
else {
return a.at - b.at;
}
}
const defaultSegmentEasing = "easeInOut";
const MAX_REPEAT = 20;
function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators, info) {
const defaultDuration = defaultTransition.duration || 0.3;
const animationDefinitions = new Map();
const sequences = new Map();
const elementCache = {};
const timeLabels = new Map();
let prevTime = 0;
let currentTime = 0;
let totalDuration = 0;
/**
* Build the timeline by mapping over the sequence array and converting
* the definitions into keyframes and offsets with absolute time values.
* These will later get converted into relative offsets in a second pass.
*
* Nested sequences are resolved recursively. `base` is the absolute
* start of the sequence being resolved, so numeric `at` values inside
* a nested sequence are relative to where it starts. Returns the time
* the sequence ends.
*/
const resolveSequence = (sequence, base) => {
let sequenceEnd = base;
const resolveTime = (at) => typeof at === "number"
? base + at
: calcNextTime(currentTime, at, prevTime, timeLabels);
for (let i = 0; i < sequence.length; i++) {
const segment = sequence[i];
/**
* If this is a timeline label, mark it and skip the rest of this iteration.
*/
if (typeof segment === "string") {
timeLabels.set(segment, currentTime);
continue;
}
else if (!Array.isArray(segment)) {
const start = segment.at === undefined
? currentTime
: resolveTime(segment.at);
if ("sequence" in segment) {
/**
* Resolve the nested sequence from its start. Afterwards
* "<" points at its start and the next segment follows
* its end, as if it were a single segment.
*/
prevTime = currentTime = start;
currentTime = resolveSequence(segment.sequence, start);
prevTime = start;
sequenceEnd = Math.max(sequenceEnd, currentTime);
}
else {
timeLabels.set(segment.name, start);
}
continue;
}
let [subject, keyframes, transition = {}] = segment;
/**
* If a relative or absolute time value has been specified we need to resolve
* it in relation to the currentTime.
*/
if (transition.at !== undefined) {
currentTime = resolveTime(transition.at);
}
/**
* Keep track of the maximum duration in this definition. This will be
* applied to currentTime once the definition has been parsed.
*/
let maxDuration = 0;
const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numSubjects = 0) => {
const valueKeyframesAsList = keyframesAsList(valueKeyframes);
const { delay = 0, times = motionDom.defaultOffset(valueKeyframesAsList), type = defaultTransition.type || "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition;
let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition;
/**
* Resolve stagger() if defined.
*/
const calculatedDelay = typeof delay === "function"
? delay(elementIndex, numSubjects)
: delay;
/**
* If this animation should and can use a spring, generate a spring easing function.
*/
const numKeyframes = valueKeyframesAsList.length;
const createGenerator = motionDom.isGenerator(type)
? type
: generators?.[type || "keyframes"];
if (numKeyframes <= 2 && createGenerator) {
/**
* As we're creating an easing function from a spring,
* ideally we want to generate it using the real distance
* between the two keyframes. However this isn't always
* possible - in these situations we use 0-100.
*/
let absoluteDelta = 100;
if (numKeyframes === 2 &&
isNumberKeyframesArray(valueKeyframesAsList)) {
const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0];
absoluteDelta = Math.abs(delta);
}
const springTransition = {
...defaultTransition,
...remainingTransition,
};
if (duration !== undefined) {
springTransition.duration =
motionUtils.secondsToMilliseconds(duration);
}
const springEasing = motionDom.createGeneratorEasing(springTransition, absoluteDelta, createGenerator);
ease = springEasing.ease;
duration = springEasing.duration;
}
duration ?? (duration = defaultDuration);
const startTime = currentTime + calculatedDelay;
/**
* If there's only one time offset of 0, fill in a second with length 1
*/
if (times.length === 1 && times[0] === 0) {
times[1] = 1;
}
/**
* Fill out if offset if fewer offsets than keyframes
*/
const remainder = times.length - valueKeyframesAsList.length;
remainder > 0 && motionDom.fillOffset(times, remainder);
/**
* If only one value has been set, ie [1], push a null to the start of
* the keyframe array. This will let us mark a keyframe at this point
* that will later be hydrated with the previous value.
*/
valueKeyframesAsList.length === 1 &&
valueKeyframesAsList.unshift(null);
/**
* Segments can't express `repeat: Infinity` or very large
* counts — they'd leave dead time after the segment or
* explode the keyframe array. Ignore with a warning.
*/
if (repeat) {
typeof process !== "undefined" &&
process.env.NODE_ENV !== "production" &&
motionUtils.warning(repeat < MAX_REPEAT, `Sequence segments can't repeat ${repeat} times — ignoring repeat option. Use a value below ${MAX_REPEAT} or apply repeat at the sequence level instead.`);
}
if (repeat && repeat < MAX_REPEAT) {
/**
* Express repeatDelay in units of a single iteration's duration
* so it can be added to the per-iteration time offsets below
* before they're normalized to 0-1.
*/
const repeatDelayUnits = duration > 0 ? repeatDelay / duration : 0;
duration = calculateRepeatDuration(duration, repeat, repeatDelay);
const originalKeyframes = [...valueKeyframesAsList];
const originalTimes = [...times];
ease = Array.isArray(ease) ? [...ease] : [ease];
const originalEase = [...ease];
/**
* For reverse/mirror, alternate iterations play the segment
* backwards. mirror matches JSAnimation's mirroredGenerator:
* reversed keyframes, easings unchanged. reverse matches
* JSAnimation's iterationProgress = 1 - p: reversed
* keyframes, easing array reversed AND each function easing
* mapped through reverseEasing (string easings unchanged —
* they're resolved later by the keyframes engine).
*/
const isFlipping = repeatType === "reverse" || repeatType === "mirror";
let flippedKeyframes = originalKeyframes;
let flippedEases = originalEase;
if (isFlipping) {
flippedKeyframes = [...originalKeyframes].reverse();
if (repeatType === "reverse") {
flippedEases = [...originalEase]
.reverse()
.map((e) => typeof e === "function"
? motionUtils.reverseEasing(e)
: e);
}
}
for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) {
const isFlipped = isFlipping && repeatIndex % 2 === 0;
const iterKeyframes = isFlipped
? flippedKeyframes
: originalKeyframes;
const iterEase = isFlipped ? flippedEases : originalEase;
const iterStartOffset = (repeatIndex + 1) * (1 + repeatDelayUnits);
/**
* If repeatDelay is set, hold the previous iteration's
* final value through the delay by inserting a keyframe
* at the moment the next iteration begins.
*/
if (repeatDelayUnits > 0) {
valueKeyframesAsList.push(valueKeyframesAsList[valueKeyframesAsList.length - 1]);
times.push(iterStartOffset);
ease.push("linear");
}
valueKeyframesAsList.push(...iterKeyframes);
for (let keyframeIndex = 0; keyframeIndex < iterKeyframes.length; keyframeIndex++) {
times.push(originalTimes[keyframeIndex] + iterStartOffset);
ease.push(keyframeIndex === 0
? "linear"
: motionUtils.getEasingForSegment(iterEase, keyframeIndex - 1));
}
}
normalizeTimes(times, repeat, repeatDelayUnits);
}
const targetTime = startTime + duration;
/**
* Add keyframes, mapping offsets to absolute time.
*/
addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime);
maxDuration = Math.max(calculatedDelay + duration, maxDuration);
totalDuration = Math.max(targetTime, totalDuration);
};
if (motionDom.isMotionValue(subject)) {
const subjectSequence = getSubjectSequence(subject, sequences);
resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence));
}
else {
const subjects = resolveSubjects(subject, keyframes, scope, elementCache);
const numSubjects = subjects.length;
/**
* For every element in this segment, process the defined values.
*/
for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) {
/**
* Cast necessary, but we know these are of this type
*/
keyframes = keyframes;
transition = transition;
const thisSubject = subjects[subjectIndex];
const subjectSequence = getSubjectSequence(thisSubject, sequences);
for (const key in keyframes) {
resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects);
}
}
}
prevTime = currentTime;
currentTime += maxDuration;
sequenceEnd = Math.max(sequenceEnd, currentTime);
}
return sequenceEnd;
};
resolveSequence(sequence, 0);
if (info) {
info.duration = totalDuration;
info.labels = timeLabels;
}
/**
* For every element and value combination create a new animation.
*/
sequences.forEach((valueSequences, element) => {
for (const key in valueSequences) {
const valueSequence = valueSequences[key];
/**
* Arrange all the keyframes in ascending time order.
*/
valueSequence.sort(compareByTime);
const keyframes = [];
const valueOffset = [];
const valueEasing = [];
/**
* For each keyframe, translate absolute times into
* relative offsets based on the total duration of the timeline.
*/
for (let i = 0; i < valueSequence.length; i++) {
const { at, value, easing } = valueSequence[i];
keyframes.push(value);
valueOffset.push(motionUtils.progress(0, totalDuration, at));
valueEasing.push(easing || "easeOut");
}
/**
* If the first keyframe doesn't land on offset: 0
* provide one by duplicating the initial keyframe. This ensures
* it snaps to the first keyframe when the animation starts.
*/
if (valueOffset[0] !== 0) {
valueOffset.unshift(0);
keyframes.unshift(keyframes[0]);
valueEasing.unshift(defaultSegmentEasing);
}
/**
* If the last keyframe doesn't land on offset: 1
* provide one with a null wildcard value. This will ensure it
* stays static until the end of the animation.
*/
if (valueOffset[valueOffset.length - 1] !== 1) {
valueOffset.push(1);
keyframes.push(null);
}
if (!animationDefinitions.has(element)) {
animationDefinitions.set(element, {
keyframes: {},
transition: {},
});
}
const definition = animationDefinitions.get(element);
definition.keyframes[key] = keyframes;
/**
* Exclude `type` from defaultTransition since springs have been
* converted to duration-based easing functions in resolveValueSequence.
* Including `type: "spring"` would cause JSAnimation to error when
* the merged keyframes array has more than 2 keyframes.
*/
const { type: _type, ...remainingDefaultTransition } = defaultTransition;
definition.transition[key] = {
...remainingDefaultTransition,
duration: totalDuration,
ease: valueEasing,
times: valueOffset,
...sequenceTransition,
};
}
});
return animationDefinitions;
}
function getSubjectSequence(subject, sequences) {
!sequences.has(subject) && sequences.set(subject, {});
return sequences.get(subject);
}
function getValueSequence(name, sequences) {
if (!sequences[name])
sequences[name] = [];
return sequences[name];
}
function keyframesAsList(keyframes) {
return Array.isArray(keyframes) ? keyframes : [keyframes];
}
function getValueTransition(transition, key) {
return transition && transition[key]
? {
...transition,
...transition[key],
}
: { ...transition };
}
const isNumber = (keyframe) => typeof keyframe === "number";
const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber);
function isSingleValue(subject, keyframes) {
return (motionDom.isMotionValue(subject) ||
typeof subject === "number" ||
(typeof subject === "string" && !isDOMKeyframes(keyframes)));
}
/**
* Implementation
*/
function animateSubject(subject, keyframes, options, scope) {
const animations = [];
if (isSingleValue(subject, keyframes)) {
animations.push(motionDom.animateSingleValue(subject, isDOMKeyframes(keyframes)
? keyframes.default || keyframes
: keyframes, options ? options.default || options : options));
}
else {
// Gracefully handle null/undefined subjects (e.g., from querySelector returning null)
if (subject == null) {
return animations;
}
const subjects = resolveSubjects(subject, keyframes, scope);
const numSubjects = subjects.length;
process.env.NODE_ENV !== "production" &&
motionUtils.invariant(Boolean(numSubjects), "No valid elements provided.", "no-valid-elements");
for (let i = 0; i < numSubjects; i++) {
const thisSubject = subjects[i];
const transition = { ...options };
/**
* Resolve stagger function if provided.
*/
if ("delay" in transition &&
typeof transition.delay === "function") {
transition.delay = transition.delay(i, numSubjects);
}
if (thisSubject instanceof Element) {
/**
* An element already owned by a VisualElement (a <motion.*>
* component or animateLayout()) animates its values so they
* share one renderer. Anything else is driven through
* styleEffect (or svgEffect) and the DOM keyframe resolver,
* without creating a VisualElement.
*/
animations.push(...motionDom.animateElement(thisSubject, keyframes, transition, motionDom.visualElementStore.get(thisSubject)));
}
else {
/**
* Registered effects (animate.addEffect) claim non-DOM
* subjects before we fall back to writing plain properties.
*/
animations.push(...motionDom.animateEffectSubject(motionDom.findEffect(thisSubject) ?? motionDom.propEffect, thisSubject, keyframes, transition));
}
}
}
return animations;
}
function animateSequence(sequence, options, scope, info) {
const animations = [];
/**
* Pre-process: replace function segments with MotionValue segments,
* subscribe callbacks immediately
*/
const processedSequence = sequence.map((segment) => {
if (Array.isArray(segment) && typeof segment[0] === "function") {
const callback = segment[0];
const mv = motionDom.motionValue(0);
mv.on("change", callback);
if (segment.length === 1) {
return [mv, [0, 1]];
}
else if (segment.length === 2) {
return [mv, [0, 1], segment[1]];
}
else {
return [mv, segment[1], segment[2]];
}
}
return segment;
});
const animationDefinitions = createAnimationsFromSequence(processedSequence, options, scope, { spring: motionDom.spring }, info);
animationDefinitions.forEach(({ keyframes, transition }, subject) => {
animations.push(...animateSubject(subject, keyframes, transition));
});
return animations;
}
let markerId = 0;
/**
* Copy a segment with a new `at`, adding a transition if it has none.
*/
function withAt(segment, at) {
const copy = [...segment];
const i = typeof copy[0] === "function" ? Math.max(1, copy.length - 1) : 2;
copy[i] = { ...copy[i], at };
return copy;
}
const label = (name, at) => at === undefined ? name : { name, at };
/**
* Rewind to the start and stop, so every value is back at the value it
* started from and the next build reads those values as its origin.
*/
function rewind(animation) {
animation.pause();
animation.time = 0;
animation.stop();
}
/**
* A sequence that can be changed after it's created. Segments, labels,
* callbacks, pauses and other timelines can be added, moved and removed
* at any point, including while it plays.
*
* Changes are batched and applied once per microtask. Each change
* rebuilds the underlying animations from the timeline's starting
* values, then restores the playhead, speed and play state, so changes
* made while playing take effect without a jump.
*/
class Timeline {
/**
* `animations` are already running animations of `items`, as
* animate() makes them. The timeline plays these until it's first
* changed, so it costs nothing over the animations themselves until
* then.
*/
constructor(items = [], options = {}, animations) {
this.options = options;
this.entries = [];
/**
* The repeat the playhead is in, to detect when it starts another.
*/
this.repeatIndex = 0;
this.prevTime = 0;
this.markers = [];
this.labels = new Map();
this.totalDuration = 0;
this.isDirty = false;
this.isDone = false;
this.isRebuilding = false;
this.playbackSpeed = 1;
/**
* True while this plays the animations it was created with, before
* any change compiles it as a sequence.
*/
this.isWrapped = false;
this.stop = () => {
this.flush();
this.group?.stop();
};
this.isPaused = options.autoplay === false;
this.newFinished();
items.forEach((item) => this.add(item));
if (animations) {
this.isDirty = false;
this.isWrapped = true;
this.group = new motionDom.GroupAnimationWithThen(animations);
this.watch();
}
else {
/**
* Build now, like animate(), so selectors resolve while a
* scope is attached. Later changes are batched.
*/
this.invalidate();
this.flush();
}
/**
* Register with the scope (for instance from useAnimate()) so it
* stops when the scope is torn down.
*/
const { scope } = options;
if (scope) {
scope.animations.push(this);
this.finished.then(() => motionUtils.removeItem(scope.animations, this));
}
}
/**
* Add a segment, label or timeline. `at` overrides any `at` already
* on the segment. The item itself is the handle for `move()` and
* `remove()`.
*/
add(item, at) {
if (item instanceof Timeline)
item.adopt(this);
return this.push({ item, at });
}
/**
* Fire a callback when the playhead crosses `at`, in either direction.
* It receives the direction: 1 forwards, -1 backwards.
*/
call(callback, at) {
return this.push({
item: callback,
at,
marker: { name: "_m" + markerId++, time: 0, callback },
});
}
/**
* Pause when the playhead reaches `at`.
*/
addPause(at, callback) {
return this.push({
item: callback || {},
at,
marker: { name: "_m" + markerId++, time: 0, callback, pause: true },
});
}
/**
* Change where an item starts.
*/
move(item, at) {
this.entries.forEach((entry) => {
if (entry.item === item)
entry.at = at;
});
return this.invalidate();
}
/**
* Remove a segment, label, callback or timeline.
*/
remove(item) {
if (item instanceof Timeline)
item.parent = undefined;
this.entries = this.entries.filter((entry) => entry.item !== item);
return this.invalidate();
}
clear() {
this.entries = [];
return this.invalidate();
}
/**
* The time of a label in seconds, or `undefined` if there's no label
* with this name.
*/
getLabelTime(name) {
this.flush();
return this.labels.get(name);
}
/**
* Jump to a time in seconds or a label.
*/
seek(position) {
this.time = this.resolve(position);
return this;
}
/**
* Play forwards or backwards to a time or label, then pause there.
*/
playTo(position) {
const target = this.resolve(position);
const speed = Math.abs(this.speed);
this.speed = target < this.time ? -speed : speed;
this.stopAt = target;
this.clock || this.invalidate();
this.play();
return this;
}
reverse() {
this.speed = -this.speed;
this.play();
return this;
}
get progress() {
return this.duration ? this.time / this.duration : 0;
}
set progress(progress) {
this.time = progress * this.duration;
}
get time() {
return this.active()?.time ?? 0;
}
set time(time) {
this.flush();
this.prevTime = time;
this.group && (this.group.time = time);
this.repeatIndex = this.currentRepeat();
}
get speed() {
return this.playbackSpeed;
}
set speed(speed) {
this.playbackSpeed = speed;
this.group && (this.group.speed = speed);
}
get duration() {
this.flush();
return this.isWrapped ? this.group.duration : this.totalDuration;
}
get iterationDuration() {
this.flush();
return this.isWrapped
? this.group.iterationDuration
: this.totalDuration;
}
get state() {
return this.active()?.state ?? "idle";
}
get startTime() {
return this.active()?.startTime ?? null;
}
play() {
this.flush();
this.isPaused = false;
this.group?.play();
/**
* Watch after playing, so it waits for the restarted animations
* rather than the ones that already finished.
*/
if (this.isDone) {
this.newFinished();
this.watch();
}
}
pause() {
this.flush();
this.isPaused = true;
this.group?.pause();
}
cancel() {
this.flush();
this.group?.cancel();
}
complete() {
this.flush();
this.group?.complete();
}
attachTimeline(timeline) {
this.flush();
this.scrollTimeline = timeline;
this.detachScroll = this.group?.attachTimeline(timeline);
return () => {
this.scrollTimeline = undefined;
this.detachScroll?.();
};
}
then(onResolve, onReject) {
return this.finished.then(onResolve, onReject);
}
/**
* The current animations, if any are running. Skipped animations
* (skipAnimations, reduced motion) can leave the group empty.
*/
active() {
this.flush();
return this.group?.animations.length ? this.group : undefined;
}
push(entry) {
this.entries.push(entry);
return this.invalidate();
}
/**
* Schedule a rebuild. A nested timeline passes changes up, as it's
* built as part of its parent.
*/
invalidate() {
if (this.parent) {
this.parent.invalidate();
}
else if (!this.isDirty) {
this.isDirty = true;
queueMicrotask(() => this.flush());
}
return this;
}
/**
* Apply pending changes now, so reads straight after a change
* reflect it.
*/
flush() {
this.isDirty && !this.parent && this.build();
}
adopt(parent) {
if (this.group) {
rewind(this.group);
this.group = undefined;
}
this.isDirty = false;
this.parent = parent;
}
build() {
this.isDirty = false;
this.isWrapped = false;
const prev = this.group;
let time = 0;
if (prev) {
this.isRebuilding = true;
if (this.scrollTimeline) {
/**
* Scroll-linked animations can't be seeked in seconds.
* Cancelling also returns them to their starting values,
* and the new build is attached to scroll instead.
*/
this.detachScroll?.();
prev.cancel();
}
else {
time = prev.animations.length ? prev.time : 0;
rewind(prev);
}
this.isRebuilding = false;
}
const { defaultTransition, onComplete, onUpdate, onRepeat, onReverseComplete, autoplay, scope, ...options } = this.options;
/**
* Inherit MotionConfig settings from a useAnimate() scope.
*/
const playback = {};
if (scope?.reduceMotion !== undefined) {
playback.reduceMotion = scope.reduceMotion;
}
if (scope?.skipAnimations !== undefined) {
playback.skipAnimations = scope.skipAnimations;
}
Object.assign(playback, options);
const markers = new Map();
const info = {};
const animations = animateSequence(this.toSequence(markers), { defaultTransition, ...playback }, scope, info);
const labels = info.labels;
let duration = playback.duration ?? info.duration;
/**
* A `duration` option stretches every animation to fit, so scale
* label and marker times to match.
*/
const scale = info.duration ? duration / info.duration : 1;
labels.forEach((time, name) => labels.set(name, time * scale));
/**
* Markers are resolved as labels. Move them out of the label map
* and extend the duration to reach any marker past the end.
*/
this.markers = [];
markers.forEach((marker, name) => {
marker.time = labels.get(name);
labels.delete(name);
duration = Math.max(duration, marker.time);
this.markers.push(marker);
});
this.markers.sort((a, b) => a.time - b.time);
this.labels = labels;
this.totalDuration = duration;
/**
* Callbacks, pauses and playTo() watch a linear clock. Without
* them, the timeline is only its animations.
*/
this.clock = this.clockAnimation = undefined;
if (this.markers.length ||
this.stopAt !== undefined ||
onUpdate ||
onRepeat) {
const clock = (this.clock = motionDom.motionValue(0));
clock.on("change", (t) => clock === this.clock && this.onClock(t));
animations.unshift((this.clockAnimation = motionDom.animateSingleValue(clock, [0, duration], {
...playback,
duration,
ease: "linear",
})));
}
const group = (this.group = new motionDom.GroupAnimationWithThen(animations));
time = Math.min(time, duration);
if (this.isDone && time < duration)
this.newFinished();
if (this.scrollTimeline) {
this.detachScroll = group.attachTimeline(this.scrollTimeline);
}
else {
group.speed = this.playbackSpeed;
if (this.isPaused)
group.pause();
if (time)
group.time = time;
}
this.repeatIndex = this.currentRepeat();
/**
* Treat a start from 0 as coming from just before it, so markers
* at 0 fire.
*/
this.prevTime = time || -1;
this.watch();
}
toSequence(markers) {
return this.entries.map(({ item, at, marker }) => {
if (marker) {
markers.set(marker.name, marker);
return label(marker.name, at);
}
if (item instanceof Timeline) {
return { sequence: item.toSequence(markers), at };
}
if (typeof item === "string")
return label(item, at);
if (at === undefined)
return item;
return Array.isArray(item)
? withAt(item, at)
: { ...item, at };
});
}
resolve(position) {
this.flush();
return typeof position === "number"
? position
: calcLabelTime(position, this.labels) ?? this.time;
}
/**
* The repeat the clock is in, from its time across all repeats.
*/
currentRepeat() {
const { repeat = 0, repeatDelay = 0, delay = 0 } = this.options;
const cycle = this.totalDuration + repeatDelay;
const time = (this.clockAnimation?.time ?? 0) - delay;
return cycle && time > 0
? Math.min(repeat, Math.floor(time / cycle))
: 0;
}
onClock(time) {
let prevTime = this.prevTime;
this.prevTime = time;
if (this.isRebuilding)
return;
const { onUpdate, onRepeat, repeatType = "loop" } = this.options;
onUpdate?.(time);
const repeatIndex = this.currentRepeat();
if (repeatIndex !== this.repeatIndex) {
this.repeatIndex = repeatIndex;
onRepeat?.();
/**
* A loop jumps the playhead back to the start, or to the
* end when playing backwards. Finish crossing the repeat
* that ended, then cross the new one from its edge.
*/
if (repeatType === "loop") {
const isForward = this.speed > 0;
if (this.cross(prevTime, isForward ? this.totalDuration : 0)) {
return;
}
prevTime = isForward ? -1 : Infinity;
}
}
this.cross(prevTime, time);
}
/**
* Fire markers between two playhead times. Returns `true` if it
* paused.
*/
cross(from, to) {
if (from === to)
return;
const isForward = to > from;
const crossed = (t) => isForward ? t > from && t <= to : t < from && t >= to;
const markers = isForward ? this.markers : [...this.markers].reverse();
for (const marker of markers) {
if (!crossed(marker.time))
continue;
marker.callback?.(isForward ? 1 : -1);
if (marker.pause)
return this.pauseAt(marker.time);
}
if (this.stopAt !== undefined && crossed(this.stopAt)) {
this.pauseAt(this.stopAt);
this.stopAt = undefined;
return true;
}
}
pauseAt(time) {
this.pause();
this.time = time;
return true;
}
watch() {
const group = this.group;
group.finished.then(() => {
if (group !== this.group || this.isDone)
return;
this.isDone = true;
const { onComplete, onReverseComplete } = this.options;
/**
* Without onReverseComplete, onComplete fires in either
* direction, as it does for a single animation.
*/
const callback = (this.speed < 0 && onReverseComplete) || onComplete;
callback?.();
this.resolveFinished();
});
}
newFinished() {
this.isDone = false;
this.finished = new Promise((resolve) => {
this.resolveFinished = resolve;
});
}
}
function isSequence(value) {
return Array.isArray(value) && value.some(Array.isArray);
}
/**
* Creates an animation function that is optionally scoped
* to a specific element.
*/
function createScopedAnimate(options = {}) {
const { scope, reduceMotion, skipAnimations } = options;
/**
* Implementation
*/
function scopedAnimate(subjectOrSequence, optionsOrKeyframes, options) {
const motionScope = motionDom.activeScope.current;
const scopeTransition = motionScope?.transition;
const inherited = {};
if (reduceMotion !== undefined)
inherited.reduceMotion = reduceMotion;
if (skipAnimations !== undefined)
inherited.skipAnimations = skipAnimations;
let timeline;
if (isSequence(subjectOrSequence)) {
const sequenceOptions = {
...inherited,
...optionsOrKeyframes,
scope,
};
if (scopeTransition) {
sequenceOptions.defaultTransition = {
...scopeTransition,
...sequenceOptions.defaultTransition,
};
}
timeline = new Timeline(subjectOrSequence, sequenceOptions);
}
else {
// Extract top-level onComplete so it doesn't get applied per-value
const { onComplete, ...rest } = options || {};
const transition = {
...inherited,
...scopeTransition,
...rest,
};
/**
* Play the animations directly, and only compile them as a
* sequence if the timeline is changed. Values animated from a
* number are animated via their onUpdate callback instead.
*/
const { onUpdate } = rest;
const segment = (typeof subjectOrSequence === "number" ||
(typeof subjectOrSequence === "string" &&
(typeof optionsOrKeyframes === "string" ||
Array.isArray(optionsOrKeyframes)))
? [
onUpdate || (() => { }),
Array.isArray(optionsOrKeyframes)
? optionsOrKeyframes
: [subjectOrSequence, optionsOrKeyframes],
{ ...transition, onUpdate: undefined },
]
: [subjectOrSequence, optionsOrKeyframes, transition]);
timeline = new Timeline([segment], { ...inherited, onComplete, scope }, animateSubject(subjectOrSequence, optionsOrKeyframes, transition, scope));
}
if (motionScope) {
timeline.finished.then(motionScope.add(() => timeline.stop()));
}
return timeline;
}
return scopedAnimate;
}
const animate = /* @__PURE__ */ Object.assign(
/* @__PURE__ */ createScopedAnimate(), {
/**
* Register an effect so `animate()` can animate the subjects it
* claims, for instance `animate.addEffect(threeEffect)`. The most
* recently added effect is tested first. DOM elements are always
* animated directly.
*/
addEffect: motionDom.addEffect,
removeEffect: motionDom.removeEffect,
});
function animateElements(elementOrSelector, keyframes, options, scope) {
// Gracefully handle null/undefined elements (e.g., from querySelector returning null)
if (elementOrSelector == null) {
return [];
}
const elements = motionDom.resolveElements(elementOrSelector, scope);
const numElements = elements.length;
typeof process !== "undefined" &&
process.env.NODE_ENV !== "production" &&
motionUtils.invariant(Boolean(numElements), "No valid elements provided.", "no-valid-elements");
/**
* WAAPI doesn't support interrupting animations.
*
* Therefore, starting animations requires a three-step process:
* 1. Stop existing animations (write styles to DOM)
* 2. Resolve keyframes (read styles from DOM)
* 3. Create new animations (write styles to DOM)
*
* The hybrid `animate()` function uses AsyncAnimation to resolve
* keyframes before creating new animations, which removes style
* thrashing. Here, we have much stricter filesize constraints.
* Therefore we do this in a synchronous way that ensures that
* at least within `animate()` calls there is no style thrashing.
*
* In the motion-native-animate-mini-interrupt benchmark this
* was 80% faster than a single loop.
*/
const animationDefinitions = [];
/**
* Step 1: Build options and stop existing animations (write)
*/
for (let i = 0; i < numElements; i++) {
const element = elements[i];
const elementTransition = { ...options };
/**
* Resolve stagger function if provided.
*/
if (typeof elementTransition.delay === "function") {
elementTransition.delay = elementTransition.delay(i, numElements);
}
for (const valueName in keyframes) {
let valueKeyframes = keyframes[valueName];
if (!Array.isArray(valueKeyframes)) {
valueKeyframes = [valueKeyframes];
}
const valueOptions = {
...motionDom.getValueTransition(elementTransition, valueName),
};
valueOptions.duration && (valueOptions.duration = motionUtils.secondsToMilliseconds(valueOptions.duration));
valueOptions.delay && (valueOptions.delay = motionUtils.secondsToMilliseconds(valueOptions.delay));
/**
* If there's an existing animation playing on this element then stop it
* before creating a new one.
*/
const map = motionDom.getAnimationMap(element);
const key = motionDom.animationMapKey(valueName, valueOptions.pseudoElement || "");
const currentAnimation = map.get(key);
currentAnimation && currentAnimation.stop();
animationDefinitions.push({
map,
key,
unresolvedKeyframes: valueKeyframes,
options: {
...valueOptions,
element,
name: valueName,
allowFlatten: !elementTransition.type && !elementTransition.ease,
},
});
}
}
/**
* Step 2: Resolve keyframes (read)
*/
for (let i = 0; i < animationDefinitions.length; i++) {
const { unresolvedKeyframes, options: animationOptions } = animationDefinitions[i];
const { element, name, pseudoElement } = animationOptions;
if (!pseudoElement && unresolvedKeyframes[0] === null) {
unresolvedKeyframes[0] = motionDom.getComputedStyle(element, name);
}
motionDom.fillWildcards(unresolvedKeyframes);
motionDom.applyPxDefaults(unresolvedKeyframes, name);
/**
* If we only have one keyframe, explicitly read the initial keyframe
* from the computed style. This is to ensure consistency with WAAPI behaviour
* for restarting animations, for instance .play() after finish, when it
* has one vs two keyframes.
*/
if (!pseudoElement && unresolvedKeyframes.length < 2) {
unresolvedKeyframes.unshift(motionDom.getComputedStyle(element, name));
}
animationOptions.keyframes = unresolvedKeyframes;
}
/**
* Step 3: Create new animations (write)
*/
const animations = [];
for (let i = 0; i < animationDefinitions.length; i++) {
const { map, key, options: animationOptions } = animationDefinitions[i];
const animation = new motionDom.NativeAnimation(animationOptions);
map.set(key, animation);
animation.finished.finally(() => map.delete(key));
animations.push(animation);
}
return animations;
}
const createScopedWaapiAnimate = (scope) => {
function scopedAnimate(elementOrSelector, keyframes, options) {
return new motionDom.GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
}
return scopedAnimate;
};
const animateMini = /*@__PURE__*/ createScopedWaapiAnimate();
/**
* A time in milliseconds, beyond which we consider the scroll velocity to be 0.
*/
const maxElapsed = 50;
const createAxisInfo = () => ({
current: 0,
offset: [],
progress: 0,
scrollLength: 0,
targetOffset: 0,
targetLength: 0,
containerLength: 0,
velocity: 0,
});
const createScrollInfo = () => ({
time: 0,
x: createAxisInfo(),
y: createAxisInfo(),
});
/**
* Also iterated with for...in as the list of axes.
*/
const axisKeys = {
x: {
length: "Width",
position: "Left",
},
y: {
length: "Height",
position: "Top",
},
};
const styles = new WeakMap();
/**
* Whether an axis scrolls from its end, which browsers report as a scroll
* position running from 0 to -scrollLength (#3340). This is read from style
* rather than the sign of the scroll position, as elastic overscroll pushes
* the position past 0 in either kind of axis (#3791).
*/
function isReversed(element, axisName) {
/**
* The viewport takes its writing mode and direction from <body>, which
* not every browser reflects in the root element's computed style. It
* doesn't adopt the root element's flex layout.
*/
const isRoot = element === document.scrollingElement;
const source = (isRoot && document.body) || element;
let style = styles.get(source);
if (!style)
styles.set(source, (style = getComputedStyle(source)));
const { writingMode, flexDirection } = style;
const isInline = (axisName === "x") !== /^[vs]/.test(writingMode);
return ((isInline
? (style.direction === "rtl") !== (writingMode === "sideways-lr")
: writingMode.endsWith("rl")) !==
/**
* A reversed flex axis flips this. The main axis is the inline axis
* unless flex-direction is a column.
*/
(!isRoot &&
style.display.includes("flex") &&
(isInline !== (flexDirection[0] === "c")
? flexDirection
: style.flexWrap).endsWith("reverse")));
}
function updateAxisInfo(element, axisName, info, time) {
const axis = info[axisName];
const { length, position } = axisKeys[axisName];
const prev = axis.current;
const prevTime = info.time;
const scrollPosition = element[`scroll${position}`];
axis.current = isReversed(element, axisName)
? -scrollPosition || 0 // Avoid -0
: scrollPosition;
axis.containerLength = element[`client${length}`];
axis.targetLength = element[`scroll${length}`];
axis.scrollLength = axis.targetLength - axis.containerLength;
axis.offset.length = 0;
axis.offset[0] = 0;
axis.offset[1] = axis.scrollLength;
axis.progress = motionUtils.clamp(0, 1, motionUtils.progress(0, axis.scrollLength, axis.current));
const elapsed = time - prevTime;
axis.velocity =
elapsed > maxElapsed
? 0
: motionUtils.velocityPerSecond(axis.current - prev, elapsed);
}
/**
* Measures a scroll container. Runs once per container per frame; every
* handler on that container derives its info from the result.
*/
function updateScrollInfo(element, info, time) {
for (const axis in axisKeys) {
updateAxisInfo(element, axis, info, time);
}
info.time = time;
}
const namedEdges = {
start: 0,
center: 0.5,
end: 1,
};
function resolveEdge(edge, length, inset = 0) {
let delta = 0;
/**
* If we have this edge defined as a preset, replace the definition
* with the numerical value.
*/
if (edge in namedEdges) {
edge = namedEdges[edge];
}
/**
* Handle unit values
*/
if (typeof edge === "string") {
const asNumber = parseFloat(edge);
if (edge.endsWith("px")) {
delta = asNumber;
}
else if (edge.endsWith("%")) {
edge = asNumber / 100;
}
else if (edge.endsWith("vw")) {
delta = (asNumber / 100) * document.documentElement.clientWidth;
}
else if (edge.endsWith("vh")) {
delta = (asNumber / 100) * document.documentElement.clientHeight;
}
else {
edge = asNumber;
}
}
/**
* If the edge is defined as a number, handle as a progress value.
*/
if (typeof edge === "number") {
delta = length * edge;
}
return inset + delta;
}
const defaultOffset = [0, 0];
function resolveOffset(offset, containerLength, targetLength, targetInset) {
let offsetDefinition = Array.isArray(offset) ? offset : defaultOffset;
let targetPoint = 0;
let containerPoint = 0;
if (typeof offset === "number") {
/**
* If we're provided offset: [0, 0.5, 1] then each number x should become
* [x, x], so we default to the behaviour of mapping 0 => 0 of both target
* and container etc.
*/
offsetDefinition = [offset, offset];
}
else if (typeof offset === "string") {
offset = offset.trim();
if (offset.includes(" ")) {
offsetDefinition = offset.split(" ");
}
else {
/**
* If we're provided a definition like "100px" then we want to apply
* that only to the top of the target point, leaving the container at 0.
* Whereas a named offset like "end" should be applied to both.
*/
offsetDefinition = [offset, namedEdges[offset] ? offset : `0`];
}
}
targetPoint = resolveEdge(offsetDefinition[0], targetLength, targetInset);
containerPoint = resolveEdge(offsetDefinition[1], containerLength);
return targetPoint - containerPoint;
}
const ScrollOffset = {
All: [
[0, 0],
[1, 1],
],
};
/**
* Resolved offsets map to evenly spaced progress values, so progress is
* derived from the segment index rather than building an interpolator.
*/
function offsetsToProgress(offsets, v) {
const n = offsets.length - 1;
if (n < 1)
return 0;
const reverse = offsets[0] > offsets[n];
const at = (i) => offsets[reverse ? n - i : i];
/**
* Matches interpolate(), which checks for a zero-length first range
* before reversing descending offsets.
*/
if (offsets[0] === offsets[1] && v < at(0))
return reverse ? 1 : 0;
let i = 0;
while (i < n - 1 && v >= at(i + 1))
i++;
const p = (i + motionUtils.progress(at(i), at(i + 1), v)) / n;
return reverse ? 1 - p : p;
}
/**
* Resolves offsets on both axes, as useScroll reports the target's progress
* on each (#2546). `axis` only picks which progress scroll() reports.
*/
function resolveOffsets(info, { offset: offsetDefinition = ScrollOffset.All }) {
for (const axis in axisKeys) {
const axisInfo = info[axis];
/**
* Reset the length of the resolved offset array rather than creating a new one.
*/
axisInfo.offset.length = 0;
/**
* Populate the offset array by resolving the user's offset definition into
* a list of pixel scroll offsets.
*/
for (let i = 0; i < offsetDefinition.length; i++) {
axisInfo.offset[i] = resolveOffset(offsetDefinition[i], axisInfo.containerLength, axisInfo.targetLength, axisInfo.targetOffset);
}
axisInfo.progress = motionUtils.clamp(0, 1, offsetsToProgress(axisInfo.offset, axisInfo.current));
}
}
function calcInset(element, container) {
const inset = { x: 0, y: 0 };
let current = element;
while (current && current !== container) {
if (motionDom.isHTMLElement(current)) {
inset.x += current.offsetLeft;
inset.y += current.offsetTop;
current = current.offsetParent;
}
else if (current.tagName === "svg") {
/**
* This isn't an ideal approach to measuring the offset of <svg /> tags.
* It would be preferable, given they behave like HTMLElements in most ways
* to use offsetLeft/Top. But these don't exist on <svg />. Likewise we
* can't use .getBBox() like most SVG elements as these provide the offset
* relative to the SVG itself, which for <svg /> is usually 0x0.
*/
const svgBoundingBox = current.getBoundingClientRect();
current = current.parentElement;
const parentBoundingBox = current.getBoundingClientRect();
inset.x += svgBoundingBox.left - parentBoundingBox.left;
inset.y += svgBoundingBox.top - parentBoundingBox.top;
}
else if (current instanceof SVGGraphicsElement) {
const { x, y } = current.getBBox();
inset.x += x;
inset.y += y;
let svg = null;
let parent = current.parentNode;
while (!svg) {
if (parent.tagName === "svg") {
svg = parent;
}
parent = current.parentNode;
}
current = svg;
}
else {
break;
}
}
return inset;
}
function getTargetSize(target) {
return "getBBox" in target && target.tagName !== "svg"
? target.getBBox()
: { width: target.clientWidth, height: target.clientHeight };
}
function createOnScrollHandler(container, onScroll, info, options = {}) {
const { target } = options;
/**
* In development mode ensure scroll containers aren't position: static as this makes
* it difficult to measure their relative positions. The document scrolling element
* is exempt: offsetParent measurements naturally resolve relative to the document.
*/
if (process.env.NODE_ENV !== "production") {
if (target &&
target !== container &&
container !== document.documentElement &&
container !== document.scrollingElement &&
container !== document.body) {
motionUtils.warnOnce(getComputedStyle(container).position !== "static", "Please ensure that the container has a non-static position, like 'relative', 'fixed', or 'absolute' to ensure scroll offset is calculated correctly.");
}
}
/**
* Handlers without a target or offset are notified with the container's
* shared info object, so they measure nothing themselves.
*/
const needsOwnInfo = target || options.offset;
return {
measure: (containerInfo) => {
if (!needsOwnInfo)
return;
info.time = containerInfo.time;
for (const key in axisKeys) {
const axis = key;
const { offset } = info[axis];
Object.assign(info[axis], containerInfo[axis]);
info[axis].offset = offset;
}
if (target && target !== container) {
const inset = calcInset(target, container);
const size = getTargetSize(target);
info.x.targetOffset = inset.x;
info.y.targetOffset = inset.y;
info.x.targetLength = size.width;
info.y.targetLength = size.height;
}
resolveOffsets(info, options);
},
notify: (containerInfo) => onScroll(needsOwnInfo ? info : containerInfo),
};
}
const scrollListeners = new WeakMap();
const resizeListeners = new WeakMap();
const onScrollHandlers = new WeakMap();
const dimensionCheckProcesses = new WeakMap();
const getEventTarget = (element) => element === document.scrollingElement ? window : element;
function scrollInfo(onScroll, { container = document.scrollingElement, trackContentSize = false, ...options } = {}) {
if (!container)
return motionUtils.noop;
let containerHandlers = onScrollHandlers.get(container);
/**
* Get the onScroll handlers for this container.
* If one isn't found, create a new one.
*/
if (!containerHandlers) {
containerHandlers = new Set();
onScrollHandlers.set(container, containerHandlers);
}
/**
* Create a new onScroll handler for the provided callback.
*/
const info = createScrollInfo();
const containerHandler = createOnScrollHandler(container, onScroll, info, options);
containerHandlers.add(containerHandler);
/**
* Check if there's a scroll event listener for this container.
* If not, create one.
*/
if (!scrollListeners.has(container)) {
const containerInfo = createScrollInfo();
const measureAll = () => {
updateScrollInfo(container, containerInfo, motionDom.frameData.timestamp);
for (const handler of containerHandlers) {
handler.measure(containerInfo);
}
motionDom.frame.preUpdate(notifyAll);
};
const notifyAll = () => {
for (const handler of containerHandlers) {
handler.notify(containerInfo);
}
};
const listener = () => motionDom.frame.read(measureAll);
scrollListeners.set(container, listener);
const target = getEventTarget(container);
window.addEventListener("resize", listener);
if (container !== document.documentElement) {
resizeListeners.set(container, motionDom.resize(container, listener));
}
target.addEventListener("scroll", listener);
listener();
}
/**
* Enable content size tracking if requested and not already enabled.
*/
if (trackContentSize && !dimensionCheckProcesses.has(container)) {
const listener = scrollListeners.get(container);
// Store initial scroll dimensions (object is reused to avoid allocation)
const size = {
width: container.scrollWidth,
height: container.scrollHeight,
};
// Add frame-based scroll dimension checking to detect content changes
const checkScrollDimensions = () => {
const newWidth = container.scrollWidth;
const newHeight = container.scrollHeight;
if (size.width !== newWidth || size.height !== newHeight) {
listener();
size.width = newWidth;
size.height = newHeight;
}
};
// Schedule with keepAlive=true to run every frame
const dimensionCheckProcess = motionDom.frame.read(checkScrollDimensions, true);
dimensionCheckProcesses.set(container, dimensionCheckProcess);
}
const listener = scrollListeners.get(container);
motionDom.frame.read(listener, false, true);
return () => {
motionDom.cancelFrame(listener);
/**
* Check if we even have any handlers for this container.
*/
const currentHandlers = onScrollHandlers.get(container);
if (!currentHandlers)
return;
currentHandlers.delete(containerHandler);
if (currentHandlers.size)
return;
/**
* If no more handlers, remove the scroll listener too. The handler
* set goes with it, as a measure still queued from this listener
* would otherwise notify handlers added by a later scrollInfo call.
*/
onScrollHandlers.delete(container);
const scrollListener = scrollListeners.get(container);
scrollListeners.delete(container);
if (scrollListener) {
getEventTarget(container).removeEventListener("scroll", scrollListener);
resizeListeners.get(container)?.();
window.removeEventListener("resize", scrollListener);
}
// Clean up scroll dimension checking
const dimensionCheckProcess = dimensionCheckProcesses.get(container);
if (dimensionCheckProcess) {
motionDom.cancelFrame(dimensionCheckProcess);
dimensionCheckProcesses.delete(container);
}
};
}
function canUseNativeTimeline(target) {
if (typeof window === "undefined")
return false;
return target ? motionDom.supportsViewTimeline() : motionDom.supportsScrollTimeline();
}
const timelineCache = new Map();
/**
* Native timelines are only attached to WAAPI animations. Offsets are
* applied to each animation as a range, so a timeline is shared by every
* offset.
*/
function getTimeline({ container, target, axis, }) {
let containerCache = timelineCache.get(container);
if (!containerCache) {
containerCache = new Map();
timelineCache.set(container, containerCache);
}
const targetKey = target ?? "self";
let targetCache = containerCache.get(targetKey);
if (!targetCache) {
targetCache = {};
containerCache.set(targetKey, targetCache);
}
return (targetCache[axis] || (targetCache[axis] = target
? new ViewTimeline({ subject: target, axis })
: new ScrollTimeline({ source: container, axis })));
}
/**
* Resolved offsets are linear in the target and container lengths, so
* probing them gives [target progress, container progress, pixels].
* vw/vh can resolve to 0px, so they're rejected up front.
*/
const toIntersection = (o) => {
if (/v/u.test(o))
return [];
const px = resolveOffset(o, 0, 0, 0);
return [resolveOffset(o, 0, 1, 0) - px, px - resolveOffset(o, 1, 0, 0), px];
};
const toRange = ([t, c, px]) => !px &&
(c === 0 || c === 1) &&
`${c ? "entry" : "exit"}-crossing ${t * 100}%`;
/**
* Maps an offset to an equivalent ViewTimeline range. Returns undefined when
* there isn't one, which signals the caller to fall back to JS-based scroll
* tracking.
*/
function offsetToViewTimelineRange(offset = ScrollOffset.All) {
if (offset.length !== 2)
return;
const [start, end] = offset.map(toIntersection);
const points = [toRange(start), toRange(end)];
const a = end[0] - start[0];
const b = start[1] - end[1];
if (points[0] && points[1] && (a || b)) {
return {
points: points,
a,
b,
cover: !start[0] && a === 1 && b === 1,
};
}
}
function attachToAnimation(animation, options) {
const { target, container, axis } = options;
const range = target && offsetToViewTimelineRange(options.offset);
/**
* Use native timeline when:
* - No target and no offset: ScrollTimeline
* - Target with mappable offset: ViewTimeline with named range
* - Otherwise: fall back to JS observe. A ScrollTimeline has no offset,
* so page offsets are applied by the JS timeline.
*/
const native = canUseNativeTimeline(target) && (target ? !!range : !options.offset);
const animations = new Map();
const observed = new Set();
let stopObserving;
let reverse = false;
/**
* When an offset's second point comes first, its range is swapped and
* the animation played backwards. The range is set after the direction,
* as that's what realigns a running animation with its timeline. Chrome
* skips that realignment when the range changes in the frame the
* animation starts, leaving it offset for good, so it's replayed too.
*/
const apply = (direction, waapi) => {
const [start, end] = range.points;
waapi.effect.updateTiming({
direction: reverse
? direction === "normal"
? "reverse"
: "alternate-reverse"
: direction,
});
Object.assign(waapi, {
rangeStart: reverse ? end : start,
rangeEnd: reverse ? start : end,
});
waapi.play();
};
const stops = [
animation.attachTimeline({
/**
* Read only by WAAPI animations, so values driven from JS don't
* create a native timeline they won't use.
*/
get timeline() {
return native ? getTimeline(options) : undefined;
},
onAttach: range &&
((waapi) => {
const direction = waapi.effect.getTiming().direction;
animations.set(waapi, direction);
apply(direction, waapi);
}),
/**
* Values driven from JS all track the offset with scrollInfo,
* which measures once per frame for all of them.
*/
observe: (valueAnimation) => {
valueAnimation.pause();
observed.add(valueAnimation);
stopObserving || (stopObserving = scrollInfo((info) => {
observed.forEach((observedAnimation) => {
observedAnimation.time =
observedAnimation.iterationDuration *
info[axis].progress;
});
}, options));
return () => observed.delete(valueAnimation);
},
}),
];
if (native && range) {
const { a, b } = range;
const length = axis === "y" ? "clientHeight" : "clientWidth";
/**
* Offsets like All only run forwards when the target is longer than
* the container, so their direction is remeasured on resize.
*/
const update = () => {
if (reverse !==
(reverse = a * target[length] + b * container[length] < 0)) {
animations.forEach(apply);
}
};
update();
if (a * b < 0) {
stops.push(motionDom.resize(update), motionDom.resize([target, container], update));
}
}
return () => {
stops.forEach((stop) => stop());
stopObserving?.();
};
}
function scroll(onScroll, { axis = "y", source, container = document.scrollingElement, ...options } = {}) {
if (!container)
return motionUtils.noop;
const optionsWithDefaults = {
axis,
container: source || container,
...options,
};
if (typeof onScroll === "function") {
const callback = motionDom.bindToScope(onScroll);
return motionDom.addToScope(scrollInfo((info) => callback(info[axis].progress, info), optionsWithDefaults));
}
return motionDom.addToScope(attachToAnimation(onScroll, optionsWithDefaults));
}
const thresholds = {
some: 0,
all: 1,
};
function inView(elementOrSelector, onStart, { root, margin: rootMargin, amount = "some" } = {}) {
const elements = motionDom.resolveElements(elementOrSelector);
onStart = motionDom.bindToScope(onStart);
const activeIntersections = new WeakMap();
const onIntersectionChange = (entries) => {
entries.forEach((entry) => {
const onEnd = activeIntersections.get(entry.target);
/**
* If there's no change to the intersection, we don't need to
* do anything here.
*/
if (entry.isIntersecting === Boolean(onEnd))
return;
if (entry.isIntersecting) {
const newOnEnd = onStart(entry.target, entry);
if (typeof newOnEnd === "function") {
activeIntersections.set(entry.target, newOnEnd);
}
else {
observer.unobserve(entry.target);
}
}
else if (typeof onEnd === "function") {
onEnd(entry);
activeIntersections.delete(entry.target);
}
});
};
const observer = new IntersectionObserver(onIntersectionChange, {
root,
rootMargin,
threshold: typeof amount === "number" ? amount : thresholds[amount],
});
elements.forEach((element) => observer.observe(element));
return motionDom.addToScope(() => observer.disconnect());
}
Object.defineProperty(exports, "delay", {
enumerable: true,
get: function () { return motionDom.delayInSeconds; }
});
exports.animate = animate;
exports.animateMini = animateMini;
exports.createScopedAnimate = createScopedAnimate;
exports.inView = inView;
exports.scroll = scroll;
exports.scrollInfo = scrollInfo;
Object.keys(motionDom).forEach(function (k) {
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
enumerable: true,
get: function () { return motionDom[k]; }
});
});
Object.keys(motionUtils).forEach(function (k) {
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
enumerable: true,
get: function () { return motionUtils[k]; }
});
});
//# sourceMappingURL=dom.js.map