Low Stock Indicator
A bar shortens once from the size of the run to what is left, then stops.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
A size that is nearly gone is marked as low beside the size picker, with no timer.
Related patterns
- Sold Out StateThe photo drains of colour and the buy action hands over to a notify control in the same slot.
- Bundle Savings HighlightItems select in turn, a bracket draws down their edge, and the combined saving arrives after it.
- Order Tracking ProgressA shipment walks its stages while the connector fills from stop to stop.
