Motion+

Add to basket

An example of flying a product into the basket along a curved path with arc() and a spring impact, in Motion.

JavaScript

Source code

<div class="stage">
    <div class="basket">
        <div class="ring"></div>
        <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="m15 11-1 9" />
            <path d="m19 11-4-7" />
            <path d="M2 11h20" />
            <path d="m3.5 11 1.6 7.4a2 2 0 0 0 2 1.6h9.8a2 2 0 0 0 2-1.6l1.7-7.4" />
            <path d="M4.5 15.5h15" />
            <path d="m5 11 4-7" />
            <path d="m9 11 1 9" />
        </svg>
    </div>

    <div class="center">
        <div class="product"><span class="glyph">👟</span></div>

        <div class="meta">
            <span class="name">Campus 00s</span>
            <span class="price">£128</span>
        </div>

        <button class="add" type="button">Add to basket</button>
    </div>
</div>

<script type="module">
    import { animate, arc, hover, motionValue, press } from "motion"

    /**
     * ==============   Constants   ================
     */

    const strength = 0.5
    const peak = 0.15
    const rotate = 0.9
    const duration = 0.45
    const basketVelocityFactor = 0.05
    const direction = "cw"
    const ease = [0.74, 0.18, 0.93, 0.69]

    const PRODUCT_SIZE = 160
    const BASKET_BOX = 56

    // Shrink the flying product down to roughly the basket's size.
    const FLY_SCALE = BASKET_BOX / PRODUCT_SIZE

    const product = document.querySelector(".product")
    const basket = document.querySelector(".basket")
    const ring = document.querySelector(".ring")
    const button = document.querySelector(".add")

    let isFlying = false

    button.addEventListener("click", async () => {
        if (isFlying) return
        isFlying = true
        button.disabled = true

        // Centre-to-centre delta so the product lands in the basket,
        // independent of the scale applied along the way.
        const from = product.getBoundingClientRect()
        const to = basket.getBoundingClientRect()
        const dx = to.left + to.width / 2 - (from.left + from.width / 2)
        const dy = to.top + to.height / 2 - (from.top + from.height / 2)

        // Probe the same x/y travel on motion values so we can read the
        // arrival velocity (the element animation's own values aren't exposed
        // in vanilla).
        const probeX = motionValue(0)
        const probeY = motionValue(0)

        // Fly into the basket along a curve, shrinking to its size and fading
        // out over the final stretch.
        await Promise.all([
            animate(
                product,
                { x: dx, y: dy, scale: FLY_SCALE, opacity: [1, 1, 0] },
                {
                    duration,
                    ease,
                    path: arc({
                        strength,
                        peak,
                        rotate,
                        direction,
                    }),
                    opacity: { inherit: true, times: [0, 0.95, 1] },
                },
            ),
            animate(probeX, dx, { duration, ease }),
            animate(probeY, dy, { duration, ease }),
        ])

        // Knock the basket with the product's own arrival velocity, then let a
        // spring settle it back to rest.
        animate(
            basket,
            { x: 0, y: 0 },
            {
                type: "spring",
                stiffness: 500,
                damping: 12,
                x: {
                    inherit: true,
                    velocity: probeX.getVelocity() * basketVelocityFactor,
                },
                y: {
                    inherit: true,
                    velocity: probeY.getVelocity() * basketVelocityFactor,
                },
            },
        )

        // Ripple an outline out from the basket as it takes the hit.
        animate(
            ring,
            { scale: [1, 2.2], opacity: [0.8, 0] },
            { duration: 0.5, ease: "easeOut" },
        )

        // Snap the now-invisible product back to its resting spot, ready to
        // reappear.
        animate(
            product,
            { x: 0, y: 0, scale: 0.9, rotate: 0, opacity: 0 },
            { duration: 0 },
        )

        // Bring a fresh product back into view, scaling in with a slight bounce.
        await animate(
            product,
            { opacity: 1, scale: 1 },
            {
                scale: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
                opacity: { duration: 0.25, ease: "easeOut" },
            },
        )

        button.disabled = false
        isFlying = false
    })

    // Hover and press feedback on the CTA, driven by Motion.
    hover(button, () => {
        animate(button, { scale: 1.03 }, { type: "spring", stiffness: 400, damping: 20 })
        return () =>
            animate(button, { scale: 1 }, { type: "spring", stiffness: 400, damping: 20 })
    })

    press(button, () => {
        animate(button, { scale: 0.97 }, { duration: 0.1 })
        return () =>
            animate(button, { scale: 1 }, { type: "spring", stiffness: 400, damping: 15 })
    })
</script>

<style>
    /* Fill the sandbox so the basket sits in the true top-right. */
    #sandbox {
        position: relative;
    }

    .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden;
        font-family: var(--font-mono);
        color: var(--foreground);
    }

    .basket {
        position: absolute;
        top: 80px;
        right: 80px;
        width: 56px;
        height: 56px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: var(--layer);
        border: 1px solid var(--border);
        color: var(--accent);
        will-change: transform;
    }

    .ring {
        position: absolute;
        inset: -1px;
        border: 1px solid var(--accent);
        opacity: 0;
        pointer-events: none;
        will-change: transform, opacity;
    }

    .center {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 18px;
    }

    .product {
        width: 160px;
        height: 160px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: var(--layer);
        border: 1px solid var(--border);
        will-change: transform, opacity;
    }

    .glyph {
        font-size: 84px;
        line-height: 1;
        user-select: none;
    }

    .meta {
        display: flex;
        align-items: baseline;
        gap: 12px;
        font-family: var(--font-mono);
        font-size: 13px;
        letter-spacing: 0.04em;
    }

    .name {
        color: var(--foreground);
    }

    .price {
        color: var(--accent);
    }

    .add {
        margin-top: 4px;
        padding: 13px 26px;
        border: none;
        background: var(--accent);
        color: var(--background);
        font-family: var(--font-mono);
        font-size: 12px;
        letter-spacing: 0.12em;
        text-transform: uppercase;
        cursor: pointer;
    }

    .add:disabled {
        opacity: 0.55;
        pointer-events: none;
    }
</style>

Related examples

Latest in JavaScript

Motion+

Unlock all 400+ examples

  • Source code for every Plus example.
  • Provide examples direct to your agent via Motion's MCP.
  • Lifetime access to new examples and APIs.