All patterns

Sold Out State

The photo drains of colour and the buy action hands over to a notify control in the same slot.

commercecalmminimalautomatic · finite · starter · ~0.8s
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.

336 lines · react + motion only
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.

Item goes out of stockTicket allocation exhaustedDiscontinued product cardWaitlist entry point

Where it shows up

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

  • NewMenWomenSale
    Men's trail shoeRidgeline GT$132Bone
    88.599.510
    Add to bag
    Free delivery and returns
    Product details
    Product page

    A drop selling out greys the product art and replaces the purchase action with a notify control.

Related patterns