A live quote line that flashes a soft directional wash on every update and blur-swaps a direction arrow beside a color-coded percentage delta, all in tabular figures.
npx shadcn@latest add @paragon/price-quote-tickerAlso installs: highlight-on-update
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { HighlightOnUpdate } from "@/registry/paragon/ui/highlight-on-update";
export interface PriceQuoteTickerProps extends React.ComponentProps<"div"> {
/** Instrument symbol, e.g. "BTC/USD". */
symbol: string;
/** Current price in major units. */
price: number;
/** Previous price used to derive direction and delta; defaults to price. */
previousPrice?: number;
/** ISO currency for formatting the price. */
currency?: string;
/** Intl locale. */
locale?: string;
/** Fraction digits for the price. */
fractionDigits?: number;
/** Optional secondary label under the symbol. */
label?: string;
}
/**
* A live quote line. On every price change the figure flashes a soft wash
* (green up, red down) and a direction arrow blur-swaps in beside a percentage
* delta — the two channels together make the move legible at a glance without
* being loud. Direction is derived from the previous price, so a flat reprint
* flashes neutral. Everything is tabular so the row never reflows as prices
* move. Reduced motion drops the flash and keeps the color-coded delta.
*/
export function PriceQuoteTicker({
symbol,
price,
previousPrice,
currency = "USD",
locale = "en-US",
fractionDigits = 2,
label,
className,
...props
}: PriceQuoteTickerProps) {
const reduced = useReducedMotion();
const prev = previousPrice ?? price;
const delta = price - prev;
const direction: "up" | "down" | "flat" =
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
const pct = prev !== 0 ? (delta / prev) * 100 : 0;
const priceFmt = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}),
[locale, currency, fractionDigits],
);
const pctFmt = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
signDisplay: "always",
}),
[locale],
);
const wash =
direction === "up" ? "positive" : direction === "down" ? "negative" : "neutral";
const deltaColor =
direction === "up"
? "text-success"
: direction === "down"
? "text-destructive"
: "text-muted-foreground";
const arrow =
direction === "up" ? (
<ArrowUp className="size-3.5" aria-hidden />
) : direction === "down" ? (
<ArrowDown className="size-3.5" aria-hidden />
) : (
<span aria-hidden className="block h-3.5 w-2 text-center leading-none">
·
</span>
);
return (
<div
className={cn(
"flex items-center justify-between gap-4 rounded-lg bg-card px-3.5 py-2.5 shadow-border",
className,
)}
{...props}
>
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium tabular-nums">{symbol}</span>
{label && (
<span className="text-[12px] text-muted-foreground">{label}</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold tabular-nums">
<HighlightOnUpdate value={price} color={wash}>
{priceFmt.format(price)}
</HighlightOnUpdate>
</span>
<span
className={cn(
"inline-flex w-[68px] items-center justify-end gap-0.5 text-[13px] font-medium tabular-nums",
deltaColor,
)}
>
{reduced ? (
arrow
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={direction}
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{arrow}
</motion.span>
</AnimatePresence>
)}
{pctFmt.format(pct)}%
</span>
</div>
</div>
);
}