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

684 lines (683 loc) 24.5 kB
import type { MotionValueChild } from './utils/motionValueChild'; import type { MotionStyle } from './utils/style'; import type { AnimationOptions, DOMKeyframesDefinition } from 'motion'; import type { Snippet } from 'svelte'; /** * A variant value: either a static keyframes object, or a factory function * that receives the consumer-provided `custom` value and returns keyframes. * * Dynamic (function-form) variants let a single variants object emit * per-instance keyframes — common for staggered lists where each child * needs its own offset or delay. * * @example * ```svelte * <motion.div * custom={index} * variants={{ * visible: (i) => ({ opacity: 1, x: i * 50 }), * hidden: { opacity: 0 } * }} * animate="visible" * /> * ``` */ /** * Keyframes returned from a variant, with optional transition timing. * * Framer Motion allows `transition` to live directly on a variant target, so * Svelte Motion accepts the same shape for parity. * * @example * ```ts * const visible: VariantTarget = { * opacity: 1, * transition: { duration: 0.2 } * } * ``` */ export type VariantTarget = DOMKeyframesDefinition & { transition?: AnimationOptions; }; export type Variant = VariantTarget | ((custom: unknown) => VariantTarget) | undefined; /** * Variants define named animation states that can be referenced by string keys. * * Each entry can be a static keyframes object or a `(custom) => keyframes` * factory function (see {@link Variant}). * * @example * ```svelte * <script> * const variants = { * open: { opacity: 1, scale: 1 }, * closed: { opacity: 0, scale: 0.8 } * } * </script> * * <motion.div variants={variants} animate="open" /> * ``` */ export type Variants = Record<string, Variant>; /** * Initial animation properties for a motion component. * * - Can be an object with animation properties * - Can be a string key referencing a variant * - Set to `false` to skip the initial animation and render directly at the animated state * * @example * ```svelte * <!-- Animate from initial to animate state --> * <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} /> * * <!-- Skip initial animation, render at animate state --> * <motion.div initial={false} animate={{ opacity: 1 }} /> * * <!-- Use variant key --> * <motion.div variants={myVariants} initial="hidden" animate="visible" /> * ``` */ export type MotionInitial = DOMKeyframesDefinition | string | string[] | false | undefined; /** * Target animation properties for a motion component. * * - Can be an object with animation properties * - Can be a string key referencing a variant * * @example * ```svelte * <motion.div animate={{ opacity: 1, scale: 1 }} /> * * <!-- With variants --> * <motion.div variants={myVariants} animate="visible" /> * ``` */ /** * Definition accepted by legacy animation controls. * * Mirrors Motion's `AnimationDefinition`: a keyframes object, a variant * label, an ordered list of variant labels, or a resolver function that * receives `custom` data. * * @example * ```ts * controls.start('visible') * controls.start(['visible', 'active']) * controls.start({ opacity: 1, x: 0 }) * controls.start((custom) => ({ x: custom * 100 })) * ``` */ export type AnimationControlsDefinition = DOMKeyframesDefinition | string | string[] | ((custom: unknown) => DOMKeyframesDefinition | string); /** * Internal subscriber shape used by {@link AnimationControls}. * * Motion's upstream controls subscribe VisualElements. Svelte Motion * subscribes a lightweight adapter from each `motion.*` component. */ export type AnimationControlsSubscriber = { /** Start an animation on the subscribed component. */ start: (definition: AnimationControlsDefinition, transitionOverride?: AnimationOptions) => Promise<unknown>; /** Synchronously set final values on the subscribed component. */ set: (definition: AnimationControlsDefinition) => void; /** Stop currently running animations on the subscribed component. */ stop: () => void; }; /** * Legacy imperative controls returned by {@link useAnimationControls}. * * Pass the object to `animate={controls}` on one or more `motion.*` * components, then call `controls.start(...)`, `controls.set(...)`, or * `controls.stop()` from events or effects. * * @example * ```svelte * <script lang="ts"> * import { motion, useAnimationControls } from '@humanspeak/svelte-motion' * * const controls = useAnimationControls() * </script> * * <button onclick={() => controls.start('open')}>Open</button> * <motion.div animate={controls} variants={{ open: { opacity: 1 } }} /> * ``` */ export type AnimationControls = { /** * Subscribe a motion component adapter to these controls. * * @param subscriber Component adapter to animate. * @returns Unsubscribe callback. */ subscribe: (subscriber: AnimationControlsSubscriber) => () => void; /** * Start an animation on every subscribed component. * * @param definition Target keyframes, variant label(s), or resolver. * @param transitionOverride Optional transition that overrides the * component/default transition for this run. * @returns Promise resolving when all subscribed animations complete. */ start: (definition: AnimationControlsDefinition, transitionOverride?: AnimationOptions) => Promise<unknown[]>; /** * Synchronously set every subscribed component to the target's final * values. * * @param definition Target keyframes, variant label(s), or resolver. */ set: (definition: AnimationControlsDefinition) => void; /** Stop animations on every subscribed component. */ stop: () => void; /** * Mark controls as mounted and return cleanup. * * Called automatically by `useAnimationControls()`. * * @returns Cleanup that marks controls unmounted and stops subscribers. */ mount: () => () => void; }; export type MotionAnimate = DOMKeyframesDefinition | string | string[] | AnimationControls | undefined; /** * Exit animation properties for a motion component when unmounted. * * - Can be an object with animation properties * - Can be a string key referencing a variant * * @example * ```svelte * <motion.div exit={{ opacity: 0, scale: 0 }} /> * * <!-- With variants --> * <motion.div variants={myVariants} exit="hidden" /> * ``` */ export type MotionExit = (Record<string, unknown> & { transition?: AnimationOptions; }) | DOMKeyframesDefinition | string | string[] | undefined; /** * Animation transition configuration. * @example * ```svelte * <motion.div * transition={{ * duration: 0.4, * scale: { * type: 'spring', * visualDuration: 0.4, * bounce: 0.5 * } * }} * /> * ``` */ export type MotionTransition = AnimationOptions | undefined; /** * Animation properties for tap/click interactions. * * Accepts inline keyframes, a variant key, or an array of variant keys * (later entries override earlier ones on key collisions). * * @example * ```svelte * <motion.button whileTap={{ scale: 0.95 }} /> * * <!-- Variant key --> * <motion.button variants={{ pressed: { scale: 0.95 } }} whileTap="pressed" /> * * <!-- Array — later wins on conflicts --> * <motion.button whileTap={["pressed", "muted"]} /> * ``` */ export type MotionWhileTap = (Record<string, unknown> & { transition?: AnimationOptions; }) | DOMKeyframesDefinition | string | string[] | undefined; /** * Animation properties for hover interactions. * * Accepts inline keyframes, a variant key, or an array of variant keys. * * @example * ```svelte * <motion.div whileHover={{ scale: 1.05 }} /> * * <!-- Variant key --> * <motion.div variants={{ hover: { scale: 1.05 } }} whileHover="hover" /> * ``` */ export type MotionWhileHover = (Record<string, unknown> & { transition?: AnimationOptions; }) | DOMKeyframesDefinition | string | string[] | undefined; /** * Animation properties for focus interactions. * * Accepts inline keyframes, a variant key, or an array of variant keys. * * @example * ```svelte * <motion.button whileFocus={{ scale: 1.05 }} /> * <motion.button variants={{ active: { outline: '2px solid blue' } }} whileFocus="active" /> * ``` */ export type MotionWhileFocus = (Record<string, unknown> & { transition?: AnimationOptions; }) | DOMKeyframesDefinition | string | string[] | undefined; /** * Animation properties for drag interactions. * When a drag gesture starts, the element animates to this state; when it ends, * it animates back to its baseline (from animate/initial), restoring only the changed keys. * * Accepts inline keyframes, a variant key, or an array of variant keys. */ export type MotionWhileDrag = (Record<string, unknown> & { transition?: AnimationOptions; }) | DOMKeyframesDefinition | string | string[] | undefined; /** * Animation properties for in-view interactions. * When the element enters the viewport, it animates to this state; when it leaves, * it animates back to its baseline (from animate/initial), restoring only the changed keys. * * Accepts inline keyframes, a variant key, or an array of variant keys. * * @example * ```svelte * <motion.div whileInView={{ opacity: 1, y: 0 }} /> * <motion.div variants={{ inView: { opacity: 1 } }} whileInView="inView" /> * ``` */ export type MotionWhileInView = (Record<string, unknown> & { transition?: AnimationOptions; }) | DOMKeyframesDefinition | string | string[] | undefined; /** * IntersectionObserver configuration for `whileInView`. Mirrors framer-motion's * `viewport` prop. Same shape as `UseInViewOptions` minus `initial` (which is * only meaningful for the hook's pre-mount return value). * * @example * ```svelte * <motion.div * whileInView={{ opacity: 1, y: 0 }} * viewport={{ once: true, amount: 0.5 }} * /> * ``` */ export type MotionViewport = { /** When `true`, fire only once on first entry. Subsequent re-entries no-op. */ once?: boolean; /** Element to use as the IntersectionObserver root. Defaults to the viewport. */ root?: Element | Document; /** 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; }; /** * Animation transition configuration for hover interactions. * Overrides the global transition when provided. */ /** * Animation lifecycle callbacks for motion components. */ export type MotionAnimationStart = ((_definition: DOMKeyframesDefinition | undefined) => void) | undefined; export type MotionAnimationComplete = ((_definition: DOMKeyframesDefinition | undefined) => void) | undefined; /** Hover lifecycle callbacks */ export type MotionOnHoverStart = (() => void) | undefined; export type MotionOnHoverEnd = (() => void) | undefined; /** Focus lifecycle callbacks */ export type MotionOnFocusStart = (() => void) | undefined; export type MotionOnFocusEnd = (() => void) | undefined; /** InView lifecycle callbacks */ export type MotionOnInViewStart = (() => void) | undefined; export type MotionOnInViewEnd = (() => void) | undefined; /** Tap lifecycle callbacks */ export type MotionOnTapStart = (() => void) | undefined; export type MotionOnTap = (() => void) | undefined; export type MotionOnTapCancel = (() => void) | undefined; /** Drag specific types */ export type DragPoint = { x: number; y: number; }; export type DragInfo = { point: DragPoint; delta: DragPoint; offset: DragPoint; velocity: DragPoint; }; export type MotionOnDragStart = ((event: PointerEvent, info: DragInfo) => void) | undefined; export type MotionOnDrag = ((event: PointerEvent, info: DragInfo) => void) | undefined; export type MotionOnDragEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined; export type MotionOnDirectionLock = ((axis: 'x' | 'y') => void) | undefined; export type MotionOnDragTransitionEnd = (() => void) | undefined; /** * Pan-gesture callbacks. PanInfo is structurally identical to DragInfo * (`{ point, delta, offset, velocity }`), so we re-use the type rather * than ship a parallel alias. */ export type MotionOnPanSessionStart = ((event: PointerEvent, info: DragInfo) => void) | undefined; export type MotionOnPanStart = ((event: PointerEvent, info: DragInfo) => void) | undefined; export type MotionOnPan = ((event: PointerEvent, info: DragInfo) => void) | undefined; export type MotionOnPanEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined; /** * Payload delivered to `onProjectionUpdate`. Re-declared structurally * here (rather than imported from `projection.ts`) so `types.ts` * stays dependency-free at the type layer. `delta.x/y.translate` is the * px shift the element's layout box moved between the pre-change * snapshot and the post-change measurement; `hasLayoutChanged` is false * only when the delta is within a tight float epsilon (±0.01px * translate), so even a sub-pixel real move is reported as a change. */ export type ProjectionUpdatePayload = { layout: { x: { min: number; max: number; }; y: { min: number; max: number; }; }; snapshot: { x: { min: number; max: number; }; y: { min: number; max: number; }; }; delta: { x: { translate: number; scale: number; origin: number; originPoint: number; }; y: { translate: number; scale: number; origin: number; originPoint: number; }; }; hasLayoutChanged: boolean; }; /** * Fires after each layout change to a `motion.*` element that has * `layout` enabled, with the FLIP delta between the pre- and * post-change layout boxes. Mirrors framer-motion's `onLayoutMeasure` * surface. Wired through the element's internal `ProjectionNode`. */ export type MotionOnProjectionUpdate = ((data: ProjectionUpdatePayload) => void) | undefined; export type DragAxis = boolean | 'x' | 'y'; export type DragConstraints = { top?: number; left?: number; right?: number; bottom?: number; } | HTMLElement; export type DragTransition = { bounceStiffness?: number; bounceDamping?: number; power?: number; timeConstant?: number; restDelta?: number; restSpeed?: number; min?: number; max?: number; }; export type DragControls = { /** Imperatively start a drag from any pointer event. */ start: (event: PointerEvent, options?: { snapToCursor?: boolean; distanceThreshold?: number; }) => void; /** Cancel an active drag without momentum. */ cancel: () => void; /** Stop current drag and any momentum animation. */ stop: () => void; /** Subscribe the controls to a target element. */ subscribe: (el: HTMLElement) => void; }; /** * Base motion props shared by all motion components. */ export type MotionProps = { /** * Unique key for AnimatePresence tracking. * Required when inside an AnimatePresence component. * Used to track enter/exit state and determine whether to animate. * * @example * ```svelte * <AnimatePresence> * {#if isVisible} * <motion.div key="box" exit={{ opacity: 0 }} /> * {/if} * </AnimatePresence> * ``` */ key?: string; /** Variants define named animation states */ variants?: Variants; /** * Value passed into function-form variants. Children without their own * `custom` prop inherit this from the nearest motion ancestor — matching * framer-motion's variant-tree custom propagation. */ custom?: unknown; /** Initial state of the animation (object or variant key) */ initial?: MotionInitial; /** Target state of the animation (object or variant key) */ animate?: MotionAnimate; /** Exit animation state when component is removed (object or variant key) */ exit?: MotionExit; /** Animation configuration */ transition?: MotionTransition; /** Tap/click interaction animation */ whileTap?: MotionWhileTap; /** Hover interaction animation */ whileHover?: MotionWhileHover; /** Focus interaction animation */ whileFocus?: MotionWhileFocus; /** Drag interaction animation */ whileDrag?: MotionWhileDrag; /** Pan interaction animation — applied while a pan gesture is active */ whilePan?: MotionWhileDrag; /** In-view interaction animation - animates when element enters viewport */ whileInView?: MotionWhileInView; /** IntersectionObserver options for `whileInView` (once / root / margin / amount) */ viewport?: MotionViewport; /** Called right before a main animate transition starts */ onAnimationStart?: MotionAnimationStart; /** Called after a main animate transition completes */ onAnimationComplete?: MotionAnimationComplete; /** Called when a true hover gesture starts (not emulated by touch) */ onHoverStart?: MotionOnHoverStart; /** Called when a true hover gesture ends */ onHoverEnd?: MotionOnHoverEnd; /** Called when element receives keyboard focus */ onFocusStart?: MotionOnFocusStart; /** Called when element loses keyboard focus */ onFocusEnd?: MotionOnFocusEnd; /** Called when element enters viewport */ onInViewStart?: MotionOnInViewStart; /** Called when element leaves viewport */ onInViewEnd?: MotionOnInViewEnd; /** Called when a tap gesture starts (pointerdown recognized) */ onTapStart?: MotionOnTapStart; /** Called when a tap gesture ends successfully (pointerup) */ onTap?: MotionOnTap; /** Called when a tap gesture is cancelled (pointercancel) */ onTapCancel?: MotionOnTapCancel; /** Called when a drag gesture starts */ onDragStart?: MotionOnDragStart; /** Called during a drag gesture */ onDrag?: MotionOnDrag; /** Called when a drag gesture ends */ onDragEnd?: MotionOnDragEnd; /** Called once when drag direction is locked to an axis */ onDirectionLock?: MotionOnDirectionLock; /** Called when the post-drag transition finishes on all axes */ onDragTransitionEnd?: MotionOnDragTransitionEnd; /** Pan gesture: fires once per pointerdown, before threshold */ onPanSessionStart?: MotionOnPanSessionStart; /** Pan gesture: fires the first frame after offset crosses threshold */ onPanStart?: MotionOnPanStart; /** Pan gesture: fires once per frame while panning */ onPan?: MotionOnPan; /** Pan gesture: fires on pointerup if onPanStart ever fired */ onPanEnd?: MotionOnPanEnd; /** Inline styles as a CSS string or Motion-style object with live MotionValue entries. */ style?: string | MotionStyle; /** CSS classes */ class?: string; /** Enable FLIP layout animations; string values select the upstream projection animation type. */ layout?: boolean | 'position' | 'size' | 'preserve-aspect'; /** * Fires after each `layout`-driven change with the FLIP delta from * the element's internal projection node. Mirrors framer-motion's * `onLayoutMeasure`. Requires `layout` to be enabled. */ onProjectionUpdate?: MotionOnProjectionUpdate; /** Shared layout animation identifier. Elements with matching layoutId animate between positions. */ layoutId?: string; /** * Mark this element as a scroll container so descendant `layout` animations * measure rects in this container's coordinate space. Without it, scrolling * mid-animation makes the FLIP transform fight the scroll and the layout * animation drifts. * * Apply on the same element as `overflow: scroll` / `overflow: auto`. * * @example * ```svelte * <motion.div layoutScroll style="overflow: auto"> * <motion.div layout /> * </motion.div> * ``` */ layoutScroll?: boolean; /** Ref to the element */ ref?: HTMLElement | null; /** Enable drag gestures. true for both axes, or lock to 'x'/'y'. */ drag?: DragAxis; /** Constrain dragging either to pixel bounds or an HTMLElement's bounding box. */ dragConstraints?: DragConstraints; /** Elasticity when overdragging beyond constraints (0 = none, 1 = full). */ dragElastic?: number; /** Continue with momentum/inertia after release (default true). */ dragMomentum?: boolean; /** Configure inertia/bounce physics for momentum. */ dragTransition?: DragTransition; /** Lock to the first detected axis of movement. */ dragDirectionLock?: boolean; /** Allow bubbling to parent drags. If false, uses a shared lock to prevent nesting. */ dragPropagation?: boolean; /** On release, animate back to origin (0). */ dragSnapToOrigin?: boolean; /** Enable the default drag listener; set false to use dragControls only. */ dragListener?: boolean; /** Pass controls to start drag imperatively from another element. */ dragControls?: DragControls; }; /** * Configuration properties for motion/animation components. * These props control how animations behave and transition between states. * * @example * ```svelte * <motion.div * transition={{ duration: 0.3, ease: "easeInOut" }} * > * Content * </motion.div> * ``` * * @property {MotionTransition} [transition] - Defines how the animation transitions between states. * Can include properties like: * - duration: Length of the animation in seconds * - ease: Easing function to use (e.g., "linear", "easeIn", "easeOut") * - delay: Time to wait before starting the animation * - repeat: Number of times to repeat the animation */ /** * Reduced-motion policy for {@link MotionConfigProps.reducedMotion}. * * - `'never'` (default): Animations run as authored, regardless of OS preference. * - `'always'`: Transform animations (x, y, scale, rotate, skew, translate) are * skipped. Other properties such as `opacity` and `color` still animate. * - `'user'`: Honors the OS-level `prefers-reduced-motion: reduce` setting — * behaves like `'always'` when the user has opted in, otherwise `'never'`. * * @see https://motion.dev/docs/react-reduced-motion */ export type ReducedMotionConfig = 'user' | 'always' | 'never'; export type MotionConfigProps = { /** Animation configuration */ transition?: MotionTransition; /** * Reduced-motion policy applied to descendant motion elements. * * Defaults to `'never'`. See {@link ReducedMotionConfig}. */ reducedMotion?: ReducedMotionConfig; }; /** * AnimatePresence mode controls how enter and exit animations are coordinated. * * - `sync` (default): Enter and exit animations happen simultaneously * - `wait`: Exit animations complete before enter animations start * - `popLayout`: Like sync, but exiting elements are removed from layout flow immediately * * @example * ```svelte * <AnimatePresence mode="wait"> * {#if isVisible} * <motion.div key="box" exit={{ opacity: 0 }} /> * {/if} * </AnimatePresence> * ``` */ export type AnimatePresenceMode = 'sync' | 'wait' | 'popLayout'; /** * Props for regular HTML elements that can have children * @example * ```svelte * <motion.div initial={{ opacity: 0 }}> * Content goes here * </motion.div> * ``` */ export type HTMLElementProps = MotionProps & { /** * Child content rendered inside the element. * * Svelte slot content arrives as a `Snippet`. For Motion parity, this * also accepts a `MotionValue<number | string>` via `children={value}`, * which renders as live text matching upstream MotionValue children. */ children?: Snippet | MotionValueChild; /** Ref to the element */ ref?: HTMLElement | null; /** Additional HTML attributes */ [key: string]: unknown; }; /** * Props for void HTML elements that cannot have children * @example * ```svelte * <motion.img src="image.jpg" initial={{ scale: 0 }} animate={{ scale: 1 }} /> * ``` */ export type HTMLVoidElementProps = MotionProps & { /** Ref to the element */ ref?: HTMLElement | null; /** Additional HTML attributes */ [key: string]: unknown; } & { /** Void elements cannot have children */ children?: never; };