Upsell Slide In
A complementary item opens the layout beneath the cart instead of covering it.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Cart
Cart pages open a suggested-item row beneath the summary rather than as an overlay.
Related patterns
- Gift Card ApplyA single sheen crosses the card while the balance eases down and the amount due settles to zero.
- Variant Swatch SwitchChoosing a colorway crossfades the product while one selection ring travels to the swatch.
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
