@humanspeak/svelte-motion
Version:
Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values
324 lines (323 loc) • 12.5 kB
JavaScript
import { animate, inView as motionInView } from 'motion';
import { createAttachable } from './attachable.js';
import { createBooleanSnapshot } from './booleanSnapshot.svelte.js';
import {} from './dom.js';
const CSS_FUNCTION_RE = /\b(var|calc|min|max|clamp|rgb|rgba|hsl|hsla|url)\s*\(/i;
/**
* Read an inline CSS-function value (var/calc/url/etc.) for `propName`
* directly from the element's style declaration. Returns `null` for missing
* or non-function values so the caller can fall back to `getComputedStyle`.
*
* Uses `style.getPropertyValue` so values like `url(data:image/svg+xml;...)`
* with nested semicolons are preserved intact - the browser has already
* parsed the declaration, no string scraping required.
*
* @param el Element whose inline style is read.
* @param propName Camel-case JS property name (e.g. `borderColor`).
* @returns The inline CSS function value, or `null`.
*/
const getInlineCssFunction = (el, propName) => {
const kebab = propName.replace(/([A-Z])/g, '-$1').toLowerCase();
const value = el.style.getPropertyValue(kebab).trim();
if (!value)
return null;
return CSS_FUNCTION_RE.test(value) ? value : null;
};
/**
* Split a `whileInView` definition into the visual keyframes and an
* optional nested `transition`. Mirrors the shape framer-motion uses
* where a single object carries both the target values and their
* timing config.
*
* Defensive against `undefined` / `null` input: `def ?? {}` ensures
* destructuring never throws, and the returned `keyframes` is then an
* empty record.
*
* @param def `whileInView` record possibly carrying a nested
* `transition` config. May be `null` / `undefined` defensively (the
* spread normalises to `{}`).
* @returns Object with the keyframes (everything *except* `transition`)
* and the extracted `transition` (or `undefined` if none was nested).
*
* @example
* ```ts
* splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
* // → { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
*
* splitInViewDefinition({ opacity: 1 })
* // → { keyframes: { opacity: 1 }, transition: undefined }
* ```
*/
export const splitInViewDefinition = (def) => {
const { transition, ...rest } = (def ?? {});
return { keyframes: rest, transition };
};
/**
* Compute the baseline values to restore to when an element leaves the
* viewport — only for the keys named in `whileInView`. Any key the
* element is not animating into stays as it was.
*
* For each key in `whileInView`, resolve a baseline by walking sources
* in this preference order:
*
* 1. `animate[key]` — the user's declared resting state
* 2. `initial[key]` — the pre-animation state
* 3. Neutral transform defaults (e.g. `x: 0`, `scale: 1`, `opacity: 1`)
* when the key is a known transform property
* 4. Inline CSS function value (`var(...)`, `calc(...)`, `url(...)`)
* read off `style.getPropertyValue` — handles cases where nested
* semicolons (e.g. `url(data:...;base64,...)`) would break a
* string-scrape
* 5. `getComputedStyle(el)[key]` — last resort
*
* The walk is per-key, so different baseline keys may be sourced from
* different layers.
*
* @param el Element whose computed style is read as the final fallback.
* Must be a real DOM node (the function reads inline style and
* `getComputedStyle`).
* @param opts Layered animation definitions:
* @param opts.initial Optional `initial` record from the component.
* @param opts.animate Optional `animate` record from the component.
* @param opts.whileInView The `whileInView` record — its keys drive
* which baseline entries get computed. Nested `transition` is
* stripped before walking.
* @returns A new record containing one entry per key found in
* `opts.whileInView`. May be empty if `whileInView` is empty.
*
* @example
* ```ts
* computeInViewBaseline(element, {
* initial: { opacity: 0, y: 50 },
* animate: { opacity: 1, y: 0 },
* whileInView: { opacity: 1, scale: 1.1 }
* })
* // → { opacity: 1, scale: 1 }
* // opacity sourced from animate; scale falls to the neutral default.
* ```
*/
export const computeInViewBaseline = (el, opts) => {
const baseline = {};
const initialRecord = (opts.initial ?? {});
const animateRecord = (opts.animate ?? {});
const whileInViewRecordRaw = (opts.whileInView ?? {});
const whileInViewRecord = { ...whileInViewRecordRaw };
delete whileInViewRecord.transition;
const neutralTransformDefaults = {
x: 0,
y: 0,
translateX: 0,
translateY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
skewX: 0,
skewY: 0,
opacity: 1
};
const cs = getComputedStyle(el);
for (const key of Object.keys(whileInViewRecord)) {
if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
baseline[key] = animateRecord[key];
}
else if (Object.prototype.hasOwnProperty.call(initialRecord, key)) {
baseline[key] = initialRecord[key];
}
else if (key in neutralTransformDefaults) {
baseline[key] = neutralTransformDefaults[key];
}
else {
const inlineValue = getInlineCssFunction(el, key);
if (inlineValue) {
baseline[key] = inlineValue;
}
else if (key in cs) {
baseline[key] = cs[key];
}
}
}
return baseline;
};
/**
* Wire a `whileInView` interaction onto an element using motion's
* `inView` primitive. On viewport entry the element animates to the
* supplied keyframes; on exit it animates back to a baseline computed
* via {@link computeInViewBaseline}.
*
* Used internally by `motion.<tag>` components to power the
* `whileInView` prop, and exposed for callers that want the same
* declarative behavior without going through a motion component.
*
* When `viewport.once` is `true`, the element latches on first entry
* — no exit animation runs, and the IntersectionObserver is detached
* via a `queueMicrotask(stop)` after the entry handler returns.
*
* @param el Target element to observe and animate.
* @param whileInView Keyframes to apply on entry. May carry a nested
* `transition` config (extracted via {@link splitInViewDefinition}).
* If `undefined`, the function returns a no-op cleanup without
* creating an observer.
* @param mergedTransition Default transition used both when
* `whileInView` has no nested `transition` and for the exit
* animation back to baseline.
* @param callbacks Optional lifecycle hooks:
* - `onStart` — fires on viewport entry, before the entry animation.
* - `onEnd` — fires on viewport exit, after the baseline restore
* animation kicks off. Not called when `viewport.once` is `true`.
* - `onAnimationComplete` — fires when the entry animation
* resolves; passed the keyframes that ran.
* @param baselineSources Sources for {@link computeInViewBaseline}'s
* per-key walk:
* - `initial` — the component's `initial` record.
* - `animate` — the component's `animate` record.
* @param viewport IntersectionObserver options:
* - `root` — scroll container (default page).
* - `margin` — `rootMargin` string.
* - `amount` — fraction visible required (defaults to `0` here so
* any pixel counts).
* - `once` — latch on first entry; skip exit animation.
* @returns A cleanup function that detaches the IntersectionObserver
* on call. Safe to invoke after a `once` latch has already fired.
*
* @example
* ```ts
* const cleanup = attachWhileInView(
* element,
* { opacity: 1, y: 0, transition: { duration: 0.5 } },
* { duration: 0.3 },
* {
* onStart: () => trackImpression(),
* onEnd: () => console.log('left viewport')
* },
* { initial: { opacity: 0, y: 50 } },
* { once: true, amount: 0.5 }
* )
* // Later — typically component teardown:
* cleanup()
* ```
*/
export const attachWhileInView = (el, whileInView, mergedTransition, callbacks, baselineSources, viewport) => {
if (!whileInView)
return () => { };
let latched = false;
const stop = motionInView(el, () => {
if (latched)
return;
const inViewBaseline = computeInViewBaseline(el, {
initial: baselineSources?.initial,
animate: baselineSources?.animate,
whileInView
});
callbacks?.onStart?.();
const { keyframes, transition } = splitInViewDefinition(whileInView);
const animation = animate(el, keyframes, (transition ?? mergedTransition));
animation.finished
.then(() => {
callbacks?.onAnimationComplete?.(keyframes);
})
.catch(() => {
/* animation cancelled — skip completion callback */
});
if (viewport?.once) {
latched = true;
queueMicrotask(stop);
return;
}
return () => {
if (Object.keys(inViewBaseline).length > 0) {
animate(el, inViewBaseline, mergedTransition);
}
callbacks?.onEnd?.();
};
}, {
root: viewport?.root,
margin: viewport?.margin,
amount: viewport?.amount ?? 0
});
return stop;
};
/**
* Returns an `InViewState` that tracks whether `target` is in the viewport.
* Mirrors framer-motion's `useInView` adapted for Svelte 5 runes.
*
* `target` (and `options.root`) accept either an `HTMLElement` directly or
* a getter `() => HTMLElement | undefined`. With Svelte's `bind:this` the
* element isn't available until after mount, so element resolution is
* deferred — if the element isn't ready, the hook polls on
* `requestAnimationFrame` until it is.
*
* Lifecycle: the IntersectionObserver is bound to the surrounding reactive
* scope via `$effect`. The observer attaches at mount and detaches at
* unmount, regardless of how many consumers are reading `.current` or
* `.subscribe()`. This is a deliberate divergence from the previous
* store-based impl, which attached lazily on first subscribe.
*
* SSR-safe: returns a static `{ current: options.initial ?? false }` when
* `window` or `IntersectionObserver` is unavailable.
*
* @param target - Element (or getter) to observe.
* @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
* `once`, `initial`).
* @returns A `InViewState` reflecting the target's viewport intersection.
* @see https://motion.dev/docs/react-use-in-view
*
* @example
* ```svelte
* <script>
* import { useInView } from '@humanspeak/svelte-motion'
*
* let ref
* const inView = useInView(() => ref, { once: true })
*
* $effect(() => {
* if (inView.current) trackImpression()
* })
* </script>
*
* <div bind:this={ref}>{inView.current ? 'visible' : 'hidden'}</div>
* ```
*/
export const useInView = (target, options = {}) => {
const initial = options.initial ?? false;
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
return {
get current() {
return initial;
},
subscribe(run) {
run(initial);
return () => undefined;
}
};
}
const [state, set] = createBooleanSnapshot(initial);
let latched = false;
const attachable = createAttachable({
refs: { target, root: options.root },
isLatched: () => latched,
onAttach: ({ target: el, root }, stop) => motionInView(el, () => {
set(true);
if (options.once) {
// Detach inside the entry callback; motion's inView
// handles re-entry safely via observer.unobserve.
latched = true;
stop();
return;
}
return () => set(false);
}, {
root: root,
// framer-motion types `margin` as a CSS-shorthand template
// literal; we expose plain `string` so the public API is
// ergonomic and forward-compat with future motion changes.
margin: options.margin,
amount: options.amount
})
});
$effect(() => attachable.subscribe());
return state;
};