A restrained "only N left" scarcity cue that enters once with a single pulse, with an optional depletion bar.
npx shadcn@latest add @paragon/stock-urgency"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>
);
}