E-commerce

Stock Urgency

A restrained "only N left" scarcity cue that enters once with a single pulse, with an optional depletion bar.

Install

npx shadcn@latest add @paragon/stock-urgency

stock-urgency.tsx

"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { Flame } from "lucide-react";
import { cn } from "@/lib/utils";

export interface StockUrgencyProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Units remaining. */
  remaining: number;
  /** Show only at or below this threshold. */
  threshold?: number;
  /** Optional total, to render a depletion bar. */
  total?: number;
  static?: boolean;
}

/**
 * A restrained scarcity cue. It enters once when scrolled into view and gives
 * exactly one pulse — no looping nag. Below the threshold it reads "Only N
 * left"; above it, it renders nothing. Reduced motion keeps the fade, drops
 * the pulse.
 */
export function StockUrgency({
  remaining,
  threshold = 10,
  total,
  static: isStatic = false,
  className,
  ...props
}: StockUrgencyProps) {
  const reduced = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.6 });

  if (remaining <= 0 || remaining > threshold) {
    // Keep the ref mounted so the observer can fire if stock later drops.
    return <div ref={ref} aria-hidden className={cn("hidden", className)} />;
  }

  const animate = inView && !isStatic && !reduced;
  const pct =
    total && total > 0
      ? Math.max(0, Math.min(1, remaining / total))
      : undefined;

  return (
    <div ref={ref} role="status" className={cn("inline-block", className)} {...props}>
      <motion.div
        initial={animate ? { opacity: 0, y: 6 } : false}
        animate={inView ? { opacity: 1, y: 0 } : undefined}
        transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
        className="inline-flex flex-col gap-1.5 text-sm font-medium text-warning"
      >
      <span className="inline-flex items-center gap-1.5">
        <motion.span
          aria-hidden
          initial={false}
          animate={
            animate
              ? { scale: [1, 1.18, 1] }
              : undefined
          }
          transition={{ duration: 0.45, ease: "easeOut", delay: 0.15 }}
          className="inline-flex"
        >
          <Flame className="size-4" />
        </motion.span>
        Only <span className="tabular-nums">{remaining}</span> left
      </span>
      {pct !== undefined && (
        <span
          aria-hidden
          className="h-1 w-32 overflow-hidden rounded-full bg-warning/15"
        >
          <motion.span
            className="block h-full origin-left rounded-full bg-warning"
            style={{ width: `${pct * 100}%` }}
            initial={animate ? { scaleX: 0 } : false}
            animate={inView ? { scaleX: 1 } : undefined}
            transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1], delay: 0.1 }}
          />
        </span>
      )}
      </motion.div>
    </div>
  );
}