@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
210 lines (209 loc) • 8.86 kB
TypeScript
import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
import type { MotionViewport } from '../types.js';
import { type BooleanSnapshot } from './booleanSnapshot.svelte.js';
import { type ElementOrGetter } from './dom.js';
/**
* 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 declare const splitInViewDefinition: (def: Record<string, unknown>) => {
keyframes: Record<string, unknown>;
transition?: AnimationOptions;
};
/**
* 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 declare const computeInViewBaseline: (el: HTMLElement, opts: {
initial?: Record<string, unknown>;
animate?: Record<string, unknown>;
whileInView?: Record<string, unknown>;
}) => Record<string, unknown>;
/**
* 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 declare const attachWhileInView: (el: HTMLElement, whileInView: Record<string, unknown> | undefined, mergedTransition: AnimationOptions, callbacks?: {
onStart?: () => void;
onEnd?: () => void;
onAnimationComplete?: (definition: DOMKeyframesDefinition | undefined) => void;
}, baselineSources?: {
initial?: Record<string, unknown>;
animate?: Record<string, unknown>;
}, viewport?: MotionViewport) => (() => void);
/**
* Options accepted by `useInView`.
*/
export type UseInViewOptions = {
/** Element to use as the IntersectionObserver root. Defaults to the viewport. */
root?: ElementOrGetter;
/** CSS margin string applied to the root bounding box (e.g. `"100px 0px"`). */
margin?: string;
/** Fraction (0-1) or `"some"` / `"all"` of the target that must be visible. */
amount?: 'some' | 'all' | number;
/** When `true`, the state latches to `true` on first entry and never flips back. */
once?: boolean;
/** Initial value emitted before the first IntersectionObserver callback. */
initial?: boolean;
};
/**
* State returned by {@link useInView}.
*/
export type InViewState = BooleanSnapshot;
/**
* 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 declare const useInView: (target: ElementOrGetter, options?: UseInViewOptions) => InViewState;