Empty Cart
The cart mark drops and settles once, then recently viewed items slide in as the way back to shopping.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Empty Cart
*
* An empty cart is a shopping session that stalled, so the block spends
* its motion on the way back in. The cart mark drops onto its line and
* settles once, the sentence follows, and the things the shopper was
* just looking at slide in from the right — the direction a carousel
* would come from, which is the point: there is more where that came
* from.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props.
* Requires the automatic JSX runtime (default since React 17).
*/
export type BrowseItem = {
name: string;
price: string;
/** Real product photograph; omit for the gradient stand-in. */
imageSrc?: string;
/** Stands in for product photography, so it stays a literal color. */
swatch: string;
};
export type EmptyCartBrowseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline of the empty cart. */
title?: string;
/** One supporting line. */
message?: string;
/** Heading over the row of items. */
sectionLabel?: string;
/** Items offered as the way back in. Three fits a phone width. */
items?: BrowseItem[];
/** Fires with the item that was picked. */
onSelect?: (item: BrowseItem) => void;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** px the cart mark descends. */
drop: number;
/** px each item card travels in from the right. */
slide: number;
/** Seconds between one card arriving and the next. */
stagger: number;
fadeSeconds: number;
cartSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the cart glyph is the only sprung element, above 0.88
// damping ratio (damping / 2√stiffness) in every variant, so it lands
// with one settle. Product cards carry prices, so they travel on tweens
// and never scale — a price that bounces looks like a price that moved.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For a cart drawer that empties and refills during one session.
subtle: {
drop: 6,
slide: 8,
stagger: 0.04,
fadeSeconds: 0.24,
cartSpring: { type: "spring", stiffness: 460, damping: 44 },
},
// The all-purpose setting: a clear drop, a clear invitation.
default: {
drop: 10,
slide: 14,
stagger: 0.065,
fadeSeconds: 0.3,
cartSpring: { type: "spring", stiffness: 370, damping: 37 },
},
// A longer arrival, for a full cart page where this is the only
// content on screen.
playful: {
drop: 15,
slide: 20,
stagger: 0.09,
fadeSeconds: 0.34,
cartSpring: { type: "spring", stiffness: 300, damping: 31 },
},
};
const ITEMS: BrowseItem[] = [
{
name: "Desk lamp",
price: "$64",
swatch: "linear-gradient(140deg, #E0A458 0%, #B9762F 100%)",
},
{
name: "Mug set",
price: "$42",
swatch: "linear-gradient(140deg, #4FA3A5 0%, #2F6E70 100%)",
},
{
name: "Storage box",
price: "$38",
swatch: "linear-gradient(140deg, #7C7CF0 0%, #4B4BB8 100%)",
},
];
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps the mark, the rule and the cards correct on light
* and dark pages. Product swatches stay literal — they stand in for
* photography. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function EmptyCartBrowse({
variant = "default",
title = "Your cart is empty",
message = "Anything you add will wait for you here.",
sectionLabel = "Recently viewed",
items = ITEMS,
onSelect,
width = 320,
}: EmptyCartBrowseProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const drop = reduceMotion ? 0 : cfg.drop;
const slide = reduceMotion ? 0 : cfg.slide;
const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };
return (
<div style={{ width, boxSizing: "border-box", padding: "24px 16px 18px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
}}
>
<motion.div
initial={{ opacity: 0, y: -drop }}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.cartSpring, opacity: fade }
}
style={{ lineHeight: 0, marginBottom: 12 }}
>
<CartMark />
</motion.div>
<motion.div
initial={{ opacity: 0, y: drop * 0.6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...fade, delay: 0.08 }}
style={{ fontSize: 15, fontWeight: 640 }}
>
{title}
</motion.div>
<motion.div
initial={{ opacity: 0, y: drop * 0.6 }}
animate={{ opacity: 0.55, y: 0 }}
transition={{ ...fade, delay: 0.14 }}
style={{ fontSize: 12.5, marginTop: 5 }}
>
{message}
</motion.div>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.45 }}
transition={{ ...fade, delay: 0.22 }}
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 0.5,
textTransform: "uppercase",
marginTop: 20,
marginBottom: 10,
paddingTop: 14,
borderTop: `1px solid ${tone(9)}`,
}}
>
{sectionLabel}
</motion.div>
<div style={{ display: "flex", gap: 9 }}>
{items.map((item, index) => (
<motion.button
key={item.name}
type="button"
onClick={() => onSelect?.(item)}
initial={{ opacity: 0, x: slide }}
animate={{ opacity: 1, x: 0 }}
transition={{ ...fade, delay: 0.26 + index * cfg.stagger }}
style={{
font: "inherit",
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
gap: 7,
padding: 8,
borderRadius: 12,
textAlign: "left",
color: "inherit",
background: tone(5),
border: `1px solid ${tone(11)}`,
cursor: "pointer",
}}
>
<span
aria-hidden
style={{
display: "block",
height: 44,
borderRadius: 8,
background: item.imageSrc
? `url(${item.imageSrc}) center / cover, ${item.swatch}`
: item.swatch,
}}
/>
<span
style={{
fontSize: 11.5,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.name}
</span>
<span style={{ fontSize: 11, opacity: 0.55, marginTop: -4 }}>
{item.price}
</span>
</motion.button>
))}
</div>
</div>
);
}
/** Line art authored inline: a cart, stroked in `currentColor` at low
* opacity so it needs no asset and inherits the page theme. */
function CartMark() {
return (
<svg width="52" height="46" viewBox="0 0 52 46" fill="none" aria-hidden>
<g
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.34"
>
<path d="M6 7h5.5l5 21.5h21L42 13H14" />
<circle cx="19.5" cy="36" r="2.8" />
<circle cx="35.5" cy="36" r="2.8" />
</g>
</svg>
);
}About this pattern
An empty cart is a stalled session rather than a missing feature, so the block spends its motion on the return route. The cart glyph descends and settles a single time, the sentence follows it, and the items the shopper was looking at a moment ago slide in from the right in a short cascade — the direction goods arrive from in a carousel, which is exactly the suggestion being made. Prices travel on tweens and never scale, because a number that bounces reads as a number that changed. Reduced motion keeps the order and drops the travel.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Cart
An empty bag pairs a light mark with a strip of recently viewed products underneath.
Related patterns
- Error RecoveryA failed panel settles without alarm, and the retry glyph turns exactly once per attempt.
- No Results SuggestAn empty result set settles into a quiet mark, then offers queries worth trying instead.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.