# Price ticker

> Prices tick with a flash of up or down.

- Type: Component (numbers)
- Page: https://textlab.dev/components/price-ticker
- Markdown: https://textlab.dev/components/price-ticker.md
- Registry item: https://textlab.dev/r/price-ticker.json
- Source file: `registry/textlab/price-ticker/price-ticker.tsx`
- Dependencies: motion
- Variants: currency, percent, compact
- Keywords: price, stock, ticker, currency, crypto, delta, up down

## When to use

- Live prices, portfolio values and conversion rates.
- Any number where the direction of change matters as much as the value.

## When not to use

- Static prices on a pricing page.
- Values that change more than a few times a second.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/price-ticker
pnpm dlx shadcn@latest add @textlab/price-ticker
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/price-ticker.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { PriceTicker } from "@/components/textlab/price-ticker/price-ticker";

export function Example() {
  return <PriceTicker value={price} currency="EUR" />;
}
```

## API reference

### PriceTicker

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | The price, or a ratio of 1 for percent. |
| `variant` | `"currency" \| "percent" \| "compact"` | `"currency"` | Money with cents, a percentage, or compact money like $1.2K. |
| `currency` | `string` | `"USD"` | ISO currency code. |
| `locale` | `string` | `"en-US"` | Formatting locale. |
| `showDelta` | `boolean` | `true` | Show the change next to the price. |
| `upColor` | `string` | `green` | Flash and delta color for rises. |
| `downColor` | `string` | `red` | Flash and delta color for falls. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- The delta is announced politely, so screen readers hear the direction.
- Direction is shown by an arrow and a sign, not color alone.

## Motion

- Digits roll up on a rise and down on a fall.
- The flash holds briefly, then fades to the text color over 1.2 s.

## Reduced motion

The price and delta update in place with no roll or flash.

## Performance

- Built on rolling-number; one spring per digit.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: currency, percent, compact.

## Related

- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.

## Also in numbers

- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.

## Source

### components/textlab/price-ticker/price-ticker.tsx

```tsx
"use client";

import { useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { RollingNumber } from "../rolling-number/rolling-number";
import { formatNumber } from "../lib/format";
import { ease } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type PriceTickerVariant = "currency" | "percent" | "compact";

export interface PriceTickerProps {
  value: number;
  /** currency formats as money, percent as a percentage of 1, compact as 1.2K style. */
  variant?: PriceTickerVariant;
  currency?: string;
  locale?: string;
  /** Show the change since the last value next to the price. */
  showDelta?: boolean;
  /** Color for rises and falls. Defaults to green and red tuned for both themes. */
  upColor?: string;
  downColor?: string;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

const FORMATS: Record<PriceTickerVariant, (currency: string) => Intl.NumberFormatOptions> = {
  currency: (currency) => ({ style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }),
  percent: () => ({ style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 }),
  compact: (currency) => ({ style: "currency", currency, notation: "compact", maximumFractionDigits: 1 }),
};

/**
 * A price that ticks to its new value with a brief flash of up or down. The flash fades on its own
 * and the arrow turns to point the way the price moved.
 */
export function PriceTicker({
  value,
  variant = "currency",
  currency = "USD",
  locale,
  showDelta = true,
  upColor = "oklch(0.62 0.16 150)",
  downColor = "oklch(0.6 0.2 25)",
  as: Tag = "span",
  reducedMotion = "user",
  className,
  style,
}: PriceTickerProps) {
  const reduced = useReduced(reducedMotion);
  const [previous, setPrevious] = useState(value);
  const [change, setChange] = useState<{ delta: number; id: number } | null>(null);

  // Derive the change while rendering, the way React recommends for state that follows a prop.
  if (value !== previous) {
    setPrevious(value);
    setChange((c) => ({ delta: value - previous, id: (c?.id ?? 0) + 1 }));
  }

  const format = FORMATS[variant](currency);
  const up = (change?.delta ?? 0) >= 0;
  const tint = change ? (up ? upColor : downColor) : undefined;
  const deltaText = change
    ? `${up ? "+" : "−"}${formatNumber(Math.abs(change.delta), locale, variant === "percent" ? { style: "percent", maximumFractionDigits: 2 } : { ...format, notation: "standard" })}`
    : "";

  return (
    <Tag className={className} style={{ display: "inline-flex", alignItems: "baseline", gap: "0.35em", whiteSpace: "nowrap", ...style }}>
      <motion.span
        key={change?.id ?? 0}
        initial={reduced || !change ? false : { color: tint }}
        animate={{ color: "currentColor" }}
        transition={{ duration: 1.2, ease: [...ease.standard], delay: 0.25 }}
        style={{ display: "inline-block" }}
      >
        <RollingNumber value={value} format={format} locale={locale} reducedMotion={reducedMotion} variant={up ? "up" : "down"} />
      </motion.span>
      {showDelta && (
        <span style={{ fontSize: "0.42em", fontVariantNumeric: "tabular-nums", minWidth: "4em" }} aria-live="polite">
          <AnimatePresence mode="popLayout" initial={false}>
            {change && (
              <motion.span
                key={change.id}
                initial={reduced ? false : { opacity: 0, transform: `translateY(${up ? "0.6em" : "-0.6em"})` }}
                animate={{ opacity: 1, transform: "translateY(0em)" }}
                exit={{ opacity: 0, transition: { duration: 0.12 } }}
                transition={{ type: "spring", visualDuration: 0.3, bounce: 0.2 }}
                style={{ display: "inline-flex", alignItems: "center", gap: "0.25em", color: up ? upColor : downColor }}
              >
                <motion.svg
                  aria-hidden="true"
                  viewBox="0 0 10 10"
                  width="0.8em"
                  height="0.8em"
                  initial={false}
                  animate={{ transform: `rotate(${up ? 0 : 180}deg)` }}
                  transition={{ duration: 0.2, ease: [...ease.enter] }}
                >
                  <path d="M5 1.5 L9 7.5 H1 Z" fill="currentColor" />
                </motion.svg>
                <span>{deltaText}</span>
              </motion.span>
            )}
          </AnimatePresence>
        </span>
      )}
    </Tag>
  );
}

export default PriceTicker;
```

Full library index: https://textlab.dev/llms.txt
