UNPKG

@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
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;