All patterns

Upsell Slide In

A complementary item opens the layout beneath the cart instead of covering it.

commercefriendlyelegantautomatic · finite · starter · ~0.6s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

366 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Upsell Slide In
 *
 * A complementary item opens beneath the cart summary rather than over
 * it: nothing the shopper was reading is covered, nothing they were
 * about to press moves under their finger. The dismiss control is a
 * full-sized labelled button beside the offer, not a hairline glyph in a
 * corner — an offer that is hard to decline is a dark pattern with a
 * spring on it.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color; the thumbnail is a
 * CSS gradient standing in for photography until you pass `imageSrc`.
 * Works with zero props; tune via `variant`, `offerName`, `imageSrc`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type UpsellSlideInProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Item being suggested. */
  offerName?: string;
  /** Price of the suggested item. */
  offerPrice?: number;
  /** What the cart comes to before the offer. */
  cartTotal?: number;
  /** Real product photograph; omit for the built-in gradient placeholder. */
  imageSrc?: string;
  /** Milliseconds before the offer appears. */
  appearAfterMs?: number;
  /** Fires with "added" or "dismissed" once the offer resolves. */
  onResolve?: (outcome: "added" | "dismissed") => void;
};

type VariantConfig = {
  /** Seconds for the row to open its height. */
  height: number;
  /** How far the card travels up as it opens, in pixels. */
  travel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Seconds between the card and its contents. */
  contentDelay: number;
};

// Quality rule: an uninvited element gets the gentlest entrance in the
// library. Height is tweened, the card settles once at a damping ratio
// at or above 0.8, and the subtotal crossfades in a fixed slot — an
// offer that bounces at someone is asking twice.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly unnoticed until you look at it. For a busy cart page.
  subtle: {
    height: 0.2,
    travel: 6,
    spring: { type: "spring", stiffness: 560, damping: 42 },
    contentDelay: 0.04,
  },
  // Opens with a soft settle. All-purpose.
  default: {
    height: 0.3,
    travel: 14,
    spring: { type: "spring", stiffness: 400, damping: 34 },
    contentDelay: 0.08,
  },
  // A longer arrival for a post-add moment where the suggestion is the
  // only thing happening.
  playful: {
    height: 0.4,
    travel: 22,
    spring: { type: "spring", stiffness: 340, damping: 31 },
    contentDelay: 0.12,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** The placeholder stands in for a photograph, so it stays literal. */
const PLACEHOLDER_ART = "linear-gradient(145deg, #3FB58A, #2E8FA8)";

const CART_LINES = [
  { id: "dripper", name: "Ceramic dripper", detail: "Matte white", price: "$38.00" },
  { id: "carafe", name: "Cold brew carafe", detail: "1 L · Smoke", price: "$54.50" },
];

const money = (value: number) =>
  `$${value.toLocaleString("en-US", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  })}`;

export default function UpsellSlideIn({
  variant = "default",
  offerName = "Paper filters, pack of 200",
  offerPrice = 9.5,
  cartTotal = 92.5,
  imageSrc,
  appearAfterMs = 800,
  onResolve,
}: UpsellSlideInProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [visible, setVisible] = useState(false);
  const [added, setAdded] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => setVisible(true), appearAfterMs);
    return () => clearTimeout(timer);
  }, [appearAfterMs]);

  const resolve = (outcome: "added" | "dismissed") => {
    if (outcome === "added") setAdded(true);
    setVisible(false);
    onResolve?.(outcome);
  };

  const total = added ? cartTotal + offerPrice : cartTotal;
  const heightTween = {
    duration: reduceMotion ? 0 : cfg.height,
    ease: "easeOut" as const,
  };

  return (
    <div
      style={{
        width: 330,
        padding: 16,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        fontFamily: "inherit",
        boxSizing: "border-box",
      }}
    >
      <div style={{ fontSize: 13, fontWeight: 650, marginBottom: 11 }}>
        Your cart
      </div>

      <div style={{ display: "grid", gap: 10 }}>
        {CART_LINES.map((line) => (
          <div
            key={line.id}
            style={{ display: "flex", alignItems: "baseline", gap: 10 }}
          >
            <span style={{ flex: 1, minWidth: 0, fontSize: 12.5 }}>
              {line.name}
              <span style={{ opacity: 0.5 }}> · {line.detail}</span>
            </span>
            <span
              style={{
                fontSize: 12.5,
                fontWeight: 600,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {line.price}
            </span>
          </div>
        ))}
      </div>

      {/* The offer opens the layout downward. It never overlays the cart,
          so nothing the shopper was reading is covered and no control
          they were reaching for moves. */}
      <AnimatePresence initial={false}>
        {visible && (
          <motion.div
            key="offer"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{
              height: 0,
              opacity: 0,
              transition: {
                height: heightTween,
                opacity: { duration: reduceMotion ? 0 : 0.14 },
              },
            }}
            transition={{
              height: heightTween,
              opacity: {
                duration: reduceMotion ? 0.16 : 0.22,
                ease: "easeOut",
              },
            }}
            style={{ overflow: "hidden" }}
          >
            <motion.div
              initial={{ y: reduceMotion ? 0 : cfg.travel, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              transition={
                reduceMotion
                  ? { duration: 0.18, ease: "easeOut" }
                  : { ...cfg.spring, delay: cfg.contentDelay }
              }
              style={{
                marginTop: 13,
                padding: 11,
                borderRadius: 13,
                background: tone(5),
                border: `1px solid ${tone(11)}`,
              }}
            >
              <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                <span
                  aria-hidden
                  style={{
                    width: 42,
                    height: 42,
                    flexShrink: 0,
                    borderRadius: 10,
                    background: PLACEHOLDER_ART,
                    overflow: "hidden",
                  }}
                >
                  {imageSrc && (
                    <img
                      src={imageSrc}
                      alt=""
                      style={{
                        width: "100%",
                        height: "100%",
                        objectFit: "cover",
                        display: "block",
                      }}
                    />
                  )}
                </span>
                <span style={{ flex: 1, minWidth: 0 }}>
                  <span
                    style={{
                      display: "block",
                      fontSize: 10.5,
                      fontWeight: 650,
                      letterSpacing: "0.05em",
                      textTransform: "uppercase",
                      color: ACCENT,
                    }}
                  >
                    Goes with this
                  </span>
                  <span
                    style={{
                      display: "block",
                      fontSize: 12.5,
                      fontWeight: 620,
                      marginTop: 2,
                    }}
                  >
                    {offerName}
                  </span>
                  <span
                    style={{
                      display: "block",
                      fontSize: 11.5,
                      opacity: 0.55,
                      fontVariantNumeric: "tabular-nums",
                    }}
                  >
                    {money(offerPrice)}
                  </span>
                </span>
              </div>

              {/* Both answers are the same size. Declining costs exactly
                  as much effort as accepting. */}
              <div style={{ display: "flex", gap: 8, marginTop: 11 }}>
                <button
                  type="button"
                  onClick={() => resolve("added")}
                  style={{
                    flex: 1,
                    padding: "8px 12px",
                    fontSize: 12.5,
                    fontWeight: 640,
                    fontFamily: "inherit",
                    borderRadius: 9,
                    border: "none",
                    background: ACCENT,
                    color: "#FFFFFF",
                    cursor: "pointer",
                  }}
                >
                  Add to cart
                </button>
                <button
                  type="button"
                  onClick={() => resolve("dismissed")}
                  style={{
                    flex: 1,
                    padding: "8px 12px",
                    fontSize: 12.5,
                    fontWeight: 600,
                    fontFamily: "inherit",
                    borderRadius: 9,
                    border: `1px solid ${tone(15)}`,
                    background: "transparent",
                    color: "inherit",
                    cursor: "pointer",
                  }}
                >
                  No thanks
                </button>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          marginTop: 13,
          paddingTop: 12,
          borderTop: `1px solid ${tone(10)}`,
        }}
      >
        <span style={{ fontSize: 13.5, fontWeight: 660 }}>Subtotal</span>
        {/* The total answers the choice by crossfading in a fixed slot —
            it translates a few pixels and never changes size. */}
        <span
          style={{
            position: "relative",
            display: "block",
            width: 96,
            height: 21,
            overflow: "hidden",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={total}
              initial={{ y: reduceMotion ? 0 : 12, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: reduceMotion ? 0 : -12, opacity: 0 }}
              transition={{ duration: 0.24, ease: "easeOut" }}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "flex-end",
                fontSize: 15,
                fontWeight: 680,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {money(total)}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>
    </div>
  );
}

About this pattern

An uninvited element gets the gentlest entrance in the library. The offer opens downward under the cart summary rather than overlaying it, so nothing the shopper was reading is covered and no control they were reaching for moves under their finger. Both answers are the same size — declining costs exactly the effort of accepting — and the subtotal answers the choice by crossfading in a fixed slot. Height is tweened, the card settles once, and nothing pulses to ask again.

Complementary item suggestionAdd-on offer in a cartAccessory recommendationPost-add cross sell

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Your bag
    Ridgeline GT — Bone, US 91+$132.00
    Merino crew sock, 2-pack2+$24.00
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Checkout
    Cart

    Cart pages open a suggested-item row beneath the summary rather than as an overlay.

Related patterns