WebberUI

Cart Arc

An arc into the bag — clicking add-to-cart sends the product thumbnail flying along a bézier arc toward the cart, where the icon squashes like jelly and the badge number pops as it increments; rapid clicks send several in flight at once.

Loading preview…
npx shadcn@latest add https://webberui.com/r/cart-arc.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

0.72
1
0
<CartArc />

Installation

npx shadcn@latest add https://webberui.com/r/cart-arc.json

Or, once registries are configured in components.json, install it as @webberui/cart-arc.

Usage

CartArc is both the container and the state hub; inside it you put a number of CartArcItem triggers and one CartArcCart target. Clicking a trigger throws a thumbnail along a bézier arc toward the cart, and on impact the icon squashes and the badge number pops as it increments.

import { CartArc, CartArcCart, CartArcItem } from "@/components/ui/cart-arc";

<CartArc>
  <div className="flex items-center justify-between">
    <span>Today's dessert</span>
    <CartArcCart />
  </div>

  <CartArcItem image="/matcha.jpg">Add to cart</CartArcItem>
</CartArc>;

To have the thumbnail take off from the product image (rather than the button), point originRef at that image element and specify what flies with flyContent:

const thumbRef = React.useRef<HTMLDivElement>(null);

<div ref={thumbRef}>
  <img src="/matcha.jpg" alt="Matcha latte" />
</div>
<CartArcItem originRef={thumbRef} flyContent={<img src="/matcha.jpg" alt="" />}>
  Add
</CartArcItem>;

Controlled total

When count is not passed, the component manages the total itself (uncontrolled); you can set the initial value with defaultCount and observe changes with onCountChange. Passing count switches it to controlled mode, where the number is driven from the outside.

const [count, setCount] = React.useState(0);

<CartArc count={count} onCountChange={setCount}>
  {/* ... */}
</CartArc>;

Imperative trigger

For advanced cases, useCartArc() lets you throw a thumbnail at any moment from any viewport coordinate:

import { useCartArc } from "@/components/ui/cart-arc";

function BuyNow() {
  const { addToCart, count } = useCartArc();
  return (
    <button
      onClick={(e) => {
        const r = e.currentTarget.getBoundingClientRect();
        addToCart(
          { x: r.left + r.width / 2, y: r.top + r.height / 2 },
          { image: "/matcha.jpg", quantity: 2 },
        );
      }}
    >
      Buy now ({count})
    </button>
  );
}

Props

CartArc

PropTypeDefaultDescription
childrenReact.ReactNodeContains CartArcItem and CartArcCart
countnumberTotal number of items in controlled mode
defaultCountnumber0Initial total in uncontrolled mode
onCountChange(count: number) => voidCallback when the total changes
durationnumber0.72Flight duration (seconds)
classNamestringStyles appended to the container

CartArcItem

Forwards all native button attributes, and additionally provides:

PropTypeDefaultDescription
imagestringImage URL for the flying thumbnail
flyContentReact.ReactNodeCustom content for the flying thumbnail; takes priority over image
quantitynumber1Quantity added, accumulated into the total
originRefReact.RefObject<HTMLElement | null>Reference element for the flight's starting point; when not provided the button itself is the origin
onClick(e) => voidRuns as usual after the thumbnail is thrown

CartArcCart

Forwards all native button attributes, and additionally provides:

PropTypeDefaultDescription
iconReact.ReactNodelucide ShoppingCartCustom cart icon

useCartArc()

ReturnsTypeDescription
countnumberCurrent total number of items in the cart
addToCart(origin, options?) => voidThrows a thumbnail from the viewport coordinate origin and increments the total; options supports image, content, and quantity

How it works and accessibility

  • The thumbnail flies along a quadratic bézier curve: the control point is lifted above both endpoints to form an upward arc, and along the way the thumbnail shrinks, fades out near the end, and rotates slightly
  • The target coordinate is measured live with getBoundingClientRect on every click, so it lands correctly through scrolling or layout changes
  • The flight layer is stacked at the very top of document.body with createPortal, carrying aria-hidden and pointer-events-none, so it interferes with neither the pointer nor assistive technology
  • Rapid clicks send several thumbnails flying at once, up to 16 concurrently; beyond that they simply count without flying, to protect performance. Every impact counts, so the total never drops one
  • On impact the icon springs back through a jelly-squash keyframe with inverted scaleX/scaleY, and the badge number is replaced with a spring pop (the old number retreats downward, the new one pops up)
  • The cart carries an aria-label stating the item count, and announces changes to the total through an aria-live="polite" region
  • The flight animation is cleaned up with stop() on unmount or when it is recycled after impact, so nothing updates after unmount
  • When the user has "reduce motion" enabled at the system level, there is no flight, no squash, and no badge pop, but the add-to-cart counting behavior works exactly as usual

On this page