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 for React.

React

Source code

"use client"

import { arc, motion, useAnimate, useMotionValue } from "motion/react"
import { useRef, useState } from "react"

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

const PRODUCT_SIZE = 160
const BASKET_BOX = 56
const FLY_SCALE = BASKET_BOX / PRODUCT_SIZE

type Direction = "auto" | "cw" | "ccw"

interface AddToBasketProps {
  strength?: number
  peak?: number
  rotate?: number
  duration?: number
  basketVelocityFactor?: number
  direction?: Direction
}

/**
 * ==============   Components   ================
 */

export default function AddToBasket({
  strength = 0.5,
  peak = 0.15,
  rotate = 0.9,
  duration = 0.45,
  basketVelocityFactor = 0.05,
  direction = "cw",
}: AddToBasketProps = {}) {
  const [scope, animate] = useAnimate()
  const productRef = useRef<HTMLDivElement>(null)
  const basketRef = useRef<HTMLDivElement>(null)
  const ringRef = useRef<HTMLDivElement>(null)
  const [isFlying, setIsFlying] = useState(false)
  const productX = useMotionValue(0)
  const productY = useMotionValue(0)

  const addToBasket = async () => {
    const product = productRef.current
    const basket = basketRef.current
    const ring = ringRef.current
    if (!product || !basket || !ring || isFlying) return
    setIsFlying(true)

    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)

    // Fly into the basket, shrinking to its size, then clip it away right
    // at the end so it disappears into the basket.
    await animate(
      product,
      {
        x: dx,
        y: dy,
        scale: FLY_SCALE,
        opacity: [1, 1, 0],
      },
      {
        duration,
        path: arc({
          strength,
          peak,
          rotate,
          direction: direction === "auto" ? undefined : direction,
        }),
        ease: [0.74, 0.18, 0.93, 0.69],
        opacity: { inherit: true, times: [0, 0.95, 1] },
      },
    )

    // Knock the basket up and to the right with an explicit impact
    // 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: productX.getVelocity() * basketVelocityFactor,
        },
        y: {
          inherit: true,
          velocity: productY.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,
        clipPath: "inset(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" },
      },
    )

    setIsFlying(false)
  }

  return (
    <div ref={scope} style={stage}>
      {/* Fill the sandbox so the basket sits in the true top-right. */}
      <style>{`#sandbox { position: relative }`}</style>

      <div ref={basketRef} style={basket}>
        <motion.div ref={ringRef} style={ring} />
        <BasketIcon />
      </div>

      <div style={center}>
        <motion.div
          ref={productRef}
          style={{ ...product, x: productX, y: productY }}
        >
          <span style={glyph}>👟</span>
        </motion.div>

        <div style={meta}>
          <span style={name}>Campus 00s</span>
          <span style={price}>£128</span>
        </div>

        <motion.button
          type="button"
          onClick={addToBasket}
          disabled={isFlying}
          whileHover={{ scale: 1.03 }}
          whileTap={{ scale: 0.97 }}
          style={{
            ...button,
            opacity: isFlying ? 0.55 : 1,
            pointerEvents: isFlying ? "none" : "auto",
          }}
        >
          Add to basket
        </motion.button>
      </div>
    </div>
  )
}

function BasketIcon() {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="26"
      height="26"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="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>
  )
}

/**
 * ==============   Styles   ================
 */

const stage: React.CSSProperties = {
  position: "absolute",
  inset: 0,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  overflow: "hidden",
  fontFamily: "var(--font-mono)",
  color: "var(--foreground)",
}

const basket: React.CSSProperties = {
  position: "absolute",
  top: 80,
  right: 80,
  width: BASKET_BOX,
  height: BASKET_BOX,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  background: "var(--layer)",
  border: "1px solid var(--border)",
  color: "var(--accent)",
  willChange: "transform",
}

const ring: React.CSSProperties = {
  position: "absolute",
  inset: -1,
  border: "1px solid var(--accent)",
  opacity: 0,
  pointerEvents: "none",
  willChange: "transform, opacity",
}

const center: React.CSSProperties = {
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  gap: 18,
}

const product: React.CSSProperties = {
  width: PRODUCT_SIZE,
  height: PRODUCT_SIZE,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  background: "var(--layer)",
  border: "1px solid var(--border)",
  willChange: "transform, opacity, clip-path",
}

const glyph: React.CSSProperties = {
  fontSize: 84,
  lineHeight: 1,
  userSelect: "none",
}

const meta: React.CSSProperties = {
  display: "flex",
  alignItems: "baseline",
  gap: 12,
  fontFamily: "var(--font-mono)",
  fontSize: 13,
  letterSpacing: "0.04em",
}

const name: React.CSSProperties = {
  color: "var(--foreground)",
}

const price: React.CSSProperties = {
  color: "var(--accent)",
}

const button: React.CSSProperties = {
  marginTop: 4,
  padding: "13px 26px",
  border: "none",
  background: "var(--accent)",
  color: "var(--background)",
  fontFamily: "var(--font-mono)",
  fontSize: 12,
  letterSpacing: "0.12em",
  textTransform: "uppercase",
  cursor: "pointer",
}

Related examples

Latest in React

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.