Fintech & Payments

Price Quote Ticker

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.

Install

npx shadcn@latest add @paragon/price-quote-ticker

Also installs: highlight-on-update

price-quote-ticker.tsx

"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>
  );
}