All patterns

Low Stock Indicator

A bar shortens once from the size of the run to what is left, then stops.

commercecalmenergeticautomatic · finite · starter · ~0.9s
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.

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

/**
 * Vibary · Low Stock Indicator
 *
 * How much is left, said once and calmly. The bar starts at the size of
 * the original run and shortens to what remains, the figure rolls into
 * place beside it, and a restock line explains what happens when it is
 * gone. Deliberately not a countdown clock: nothing ticks, nothing
 * flashes, and the level never moves again after it has settled.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The track is mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `remaining`, `total`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LowStockIndicatorProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Units still available. */
  remaining?: number;
  /** Size of the run this level is measured against. */
  total?: number;
  /** What the units are, singular. */
  unitLabel?: string;
  /** Heading above the bar. */
  title?: string;
  /** Calm sentence about what happens when the run is gone. */
  restockNote?: string;
  /** Below this share of the run the bar takes the low tint. */
  lowThreshold?: number;
  /** Beat before the level settles, in ms. */
  delayMs?: number;
  /** Accent used while stock is comfortable. */
  accent?: string;
  /** Fires once the level has settled. */
  onSettled?: () => void;
};

type VariantConfig = {
  /** How long the bar takes to reach its level. */
  levelSeconds: number;
  /** Crossfade for the figure and the note. */
  fadeSeconds: number;
  /** Beat between the bar settling and the note arriving. */
  noteDelay: number;
};

// This is a fact, not an alarm. Everything here is a plain eased tween:
// no springs, no pulsing, no repeat — a level that keeps moving reads as
// pressure rather than as information. Variants differ only in pace.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost immediate. For a listing grid where every card carries one.
  subtle: {
    levelSeconds: 0.34,
    fadeSeconds: 0.16,
    noteDelay: 0.08,
  },
  // Slow enough to see where the level came from. All-purpose.
  default: {
    levelSeconds: 0.62,
    fadeSeconds: 0.22,
    noteDelay: 0.16,
  },
  // A longer travel for a product page where availability is part of the
  // decision.
  playful: {
    levelSeconds: 0.86,
    fadeSeconds: 0.26,
    noteDelay: 0.24,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The two level colors stay literal — they carry meaning. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** A warm, quiet tint. Not red: red is for errors, and running low is
 *  not an error. */
const LOW = "#C97B54";

export default function LowStockIndicator({
  variant = "default",
  remaining = 8,
  total = 40,
  unitLabel = "left",
  title = "Availability",
  restockNote = "We make this line in batches — the next one lands in about three weeks.",
  lowThreshold = 0.25,
  delayMs = 450,
  accent = "#7C7CF0",
  onSettled,
}: LowStockIndicatorProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const safeTotal = Math.max(total, 1);
  const level = Math.min(Math.max(remaining, 0), safeTotal) / safeTotal;
  const low = level <= lowThreshold;

  const [settled, setSettled] = useState(false);

  const onSettledRef = useRef(onSettled);
  useEffect(() => {
    onSettledRef.current = onSettled;
  }, [onSettled]);

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

  return (
    <div
      style={{
        width: 268,
        padding: "14px 16px 15px",
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(10)}`,
        fontSize: 13,
      }}
    >
      <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
        <span
          style={{
            fontSize: 11.5,
            fontWeight: 600,
            opacity: 0.55,
            letterSpacing: 0.2,
            textTransform: "uppercase",
          }}
        >
          {title}
        </span>
        {/* The figure rolls: it travels a few pixels and crossfades, and
            it holds one type size the whole way. A number that grows to
            make a point is a number nobody trusts. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            height: 18,
            overflow: "hidden",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={settled ? "settled" : "pending"}
              initial={{ y: reduceMotion ? 0 : 18, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: reduceMotion ? 0 : -18, opacity: 0 }}
              transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
              style={{
                gridArea: "1 / 1",
                fontSize: 12.5,
                fontWeight: 600,
                lineHeight: "18px",
                whiteSpace: "nowrap",
                fontVariantNumeric: "tabular-nums",
                color: settled && low ? LOW : "inherit",
              }}
            >
              {settled
                ? `${remaining} of ${safeTotal} ${unitLabel}`
                : `${safeTotal} made`}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>

      {/* The track is the size of the original run, so the fill reads as
          "this much of that" rather than as a bar filling up from zero. */}
      <div
        role="meter"
        aria-valuemin={0}
        aria-valuemax={safeTotal}
        aria-valuenow={remaining}
        aria-label={`${remaining} of ${safeTotal} ${unitLabel}`}
        style={{
          position: "relative",
          height: 8,
          marginTop: 11,
          borderRadius: 999,
          background: tone(10),
          overflow: "hidden",
        }}
      >
        {/* scaleX rather than width: the level is a transform, so the
            panel never pays for a layout pass while it settles. */}
        <motion.div
          // The bar starts full and in the resting color, so the only
          // thing the reader sees change is the level and the tint.
          initial={{ scaleX: 1, backgroundColor: accent }}
          animate={{
            scaleX: settled ? level : 1,
            backgroundColor: settled && low ? LOW : accent,
          }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : {
                  scaleX: { duration: cfg.levelSeconds, ease: [0.32, 0, 0.2, 1] },
                  backgroundColor: {
                    duration: cfg.levelSeconds * 0.7,
                    ease: "easeOut",
                  },
                }
          }
          style={{
            height: "100%",
            borderRadius: 999,
            transformOrigin: "0% 50%",
          }}
        />
      </div>

      <motion.div
        initial={false}
        animate={{ opacity: settled ? 1 : 0 }}
        transition={{
          duration: cfg.fadeSeconds,
          ease: "easeOut",
          delay: settled && !reduceMotion ? cfg.noteDelay : 0,
        }}
        style={{
          display: "flex",
          alignItems: "flex-start",
          gap: 7,
          marginTop: 11,
        }}
      >
        <svg
          width="13"
          height="13"
          viewBox="0 0 16 16"
          fill="none"
          aria-hidden
          style={{ flexShrink: 0, marginTop: 1, opacity: 0.55 }}
        >
          <circle cx="8" cy="8" r="6.4" stroke="currentColor" strokeWidth="1.4" />
          <path
            d="M8 7.2v4"
            stroke="currentColor"
            strokeWidth="1.6"
            strokeLinecap="round"
          />
          <circle cx="8" cy="4.9" r="0.9" fill="currentColor" />
        </svg>
        <span style={{ fontSize: 11.5, opacity: 0.6, lineHeight: 1.4 }}>
          {restockNote}
        </span>
      </motion.div>
    </div>
  );
}

About this pattern

Availability stated as a fact rather than as pressure. The track is the size of the original run and the fill shortens to what remains, so the bar reads as this much of that instead of as something filling up from zero. The figure rolls into place beside it and a restock line explains what happens when the run is gone. Everything is a single eased tween: no springs, no pulsing, no repeat, and above all no clock — a level that keeps moving is a sales tactic, not information, and shoppers stop believing it. Below a quarter remaining the fill takes a warm tint; deliberately not red, because running low is not an error.

Stock level on a product pageSeats or tickets still availableLimited production run remainingInventory note beside a cart line

Where it shows up

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

  • StoreDevicesAudioAccessoriesSupport
    NewAster Studio 14From $1,299
    Finish — Slate
    Buy
    Product page

    A size that is nearly gone is marked as low beside the size picker, with no timer.

Related patterns