Sold Out State
The photo drains of colour and the buy action hands over to a notify control in the same slot.
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 · Sold Out State
*
* Stock runs out while the shopper is looking at the item. The photo
* drains of colour, the availability dot goes quiet, and the buy action
* hands over to a notify control — the page loses its offer without
* losing its shape, so nothing below it jumps.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color; the product image is
* a CSS gradient standing in for photography until you pass `imageSrc`.
* Works with zero props; tune via `variant`, `productName`, `imageSrc`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SoldOutStateProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Item on the card. */
productName?: string;
/** Price shown beside the name. */
price?: string;
/** Real product photograph; omit for the built-in gradient placeholder. */
imageSrc?: string;
/** Milliseconds before stock runs out, for demonstrating the change. */
soldOutAfterMs?: number;
/** Fires when the shopper asks to be told about a restock. */
onNotify?: () => void;
};
type VariantConfig = {
/** Seconds for the photo to drain of colour. */
drain: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** How far the action swap travels, in pixels. */
travel: number;
};
// Quality rule: an item going unavailable is bad news, and bad news
// delivered with a bounce reads as a taunt. Every spring is at or above
// a 0.8 damping ratio and the action swaps inside a fixed-height slot,
// so the card never resizes and no label is ever scaled.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a cut, for a grid where several cards may change at once.
subtle: {
drain: 0.28,
spring: { type: "spring", stiffness: 540, damping: 42 },
travel: 8,
},
// The colour leaves at a readable pace. All-purpose.
default: {
drain: 0.48,
spring: { type: "spring", stiffness: 400, damping: 34 },
travel: 16,
},
// A slower drain for a product page, where the change deserves to be
// noticed rather than discovered.
playful: {
drain: 0.68,
spring: { type: "spring", stiffness: 340, damping: 31 },
travel: 24,
},
};
const IN_STOCK = "#2F9E6E";
const ACCENT = "#5B7CFA";
/** 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 — it
* is an image, not a surface that should follow the page theme. */
const PLACEHOLDER_ART =
"linear-gradient(140deg, #E0864A 0%, #C9546B 52%, #7C4FA0 100%)";
export default function SoldOutState({
variant = "default",
productName = "Enamel pour-over kettle",
price = "$74.00",
imageSrc,
soldOutAfterMs = 700,
onNotify,
}: SoldOutStateProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [soldOut, setSoldOut] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setSoldOut(true), soldOutAfterMs);
return () => clearTimeout(timer);
}, [soldOutAfterMs]);
const drain = reduceMotion
? { duration: 0.15, ease: "easeOut" as const }
: { duration: cfg.drain, ease: "easeOut" as const };
const swap = reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: cfg.spring;
const travel = reduceMotion ? 0 : cfg.travel;
return (
<div
style={{
width: 300,
padding: 14,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<div
style={{
position: "relative",
height: 148,
borderRadius: 13,
overflow: "hidden",
}}
>
{/* Saturation carries the state change on its own: no crossed-out
overlay, no red wash. The photo simply stops being an offer. */}
<motion.div
initial={false}
animate={{
filter: soldOut ? "grayscale(1)" : "grayscale(0)",
opacity: soldOut ? 0.62 : 1,
}}
transition={drain}
style={{
position: "absolute",
inset: 0,
background: PLACEHOLDER_ART,
}}
>
{imageSrc && (
<img
src={imageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
)}
</motion.div>
<AnimatePresence>
{soldOut && (
<motion.span
key="badge"
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ ...swap, delay: reduceMotion ? 0 : cfg.drain * 0.4 }}
style={{
position: "absolute",
top: 10,
left: 10,
padding: "4px 9px",
fontSize: 10.5,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
borderRadius: 999,
background: "Canvas",
color: "CanvasText",
boxShadow: "0 4px 14px rgba(0,0,0,0.22)",
}}
>
Sold out
</motion.span>
)}
</AnimatePresence>
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
marginTop: 12,
}}
>
<span style={{ fontSize: 13.5, fontWeight: 650 }}>{productName}</span>
<span
style={{
fontSize: 13,
fontWeight: 620,
fontVariantNumeric: "tabular-nums",
}}
>
{price}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
height: 15,
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ backgroundColor: soldOut ? tone(28) : IN_STOCK }}
transition={drain}
style={{ width: 7, height: 7, borderRadius: 999 }}
/>
{/* The status line crossfades in a fixed-height row: the text
changes, the layout does not, and nothing below it moves. */}
<span style={{ position: "relative", flex: 1, height: 15 }}>
<AnimatePresence initial={false}>
<motion.span
key={soldOut ? "out" : "in"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 11.5,
lineHeight: "15px",
opacity: 0.62,
}}
>
{soldOut
? "Out of stock · restocks most weeks"
: "In stock · ships tomorrow"}
</motion.span>
</AnimatePresence>
</span>
</div>
{/* One slot, two actions. Swapping inside a fixed height is what
keeps the rest of the page still while the offer changes. */}
<div style={{ position: "relative", height: 38, marginTop: 12 }}>
<AnimatePresence initial={false}>
{!soldOut && (
<motion.button
key="buy"
type="button"
initial={false}
exit={{
opacity: 0,
y: -travel,
transition: { duration: 0.18, ease: "easeIn" },
}}
style={{
position: "absolute",
inset: 0,
width: "100%",
fontSize: 13,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 11,
border: "none",
background: ACCENT,
color: "#FFFFFF",
cursor: "pointer",
}}
>
Add to cart
</motion.button>
)}
{soldOut && (
<motion.button
key="notify"
type="button"
onClick={onNotify}
initial={{ opacity: 0, y: travel }}
animate={{ opacity: 1, y: 0 }}
transition={{
...swap,
delay: reduceMotion ? 0.05 : 0.1,
opacity: {
duration: 0.22,
ease: "easeOut",
delay: reduceMotion ? 0.05 : 0.1,
},
}}
style={{
position: "absolute",
inset: 0,
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 7,
fontSize: 13,
fontWeight: 620,
fontFamily: "inherit",
borderRadius: 11,
border: `1px solid ${tone(18)}`,
background: tone(8),
color: "inherit",
cursor: "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M2.5 5.5h15v10h-15z" />
<path d="m2.5 6.2 7.5 5.3 7.5-5.3" />
</svg>
Email me when it is back
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}About this pattern
An item becoming unavailable while someone is looking at it. Saturation carries the change on its own — no crossed-out overlay, no red wash — so the picture simply stops reading as an offer. The action swaps inside a fixed-height slot and the status line crossfades inside a fixed-height row, which means the card keeps its exact shape and nothing below it jumps. Bad news delivered with a bounce reads as a taunt, so every spring here settles once and stops.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
A drop selling out greys the product art and replaces the purchase action with a notify control.
Related patterns
- Cart Item RemoveA removed line collapses its own height and gap while the survivors travel up and the total falls.
- Low Stock IndicatorA bar shortens once from the size of the run to what is left, then stops.
- Shipping Option SelectOne plate travels to the chosen speed while the arrival estimate restates itself and the total follows.
