Skip to contentVibraUI
Metrics

Ticker

A strip of label · value · change, with the values rolling to their new readings.

Each value is a NumberRoll, so a new reading rolls to its place rather than cutting to it and the number a screen reader hears is always the true one rather than a frame of the roll; the roll never runs on mount, so the first frame is already right. A change is a MetricDelta, which puts the direction in a word and an arrow before the colour. scrolling hands the whole strip to Marquee — duplicated for the loop, parked on hover and focus, still under reduced motion; without it the row scrolls sideways under the reader's own finger instead, which is what keeps it usable at 390px. Give each item its own format for the ones that are not counts: a price, a duration, a percentage.

Install

npx shadcn@latest add @vibra/ticker

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
items{ key: string; label: string; value: number; delta?: number; format?: (n: number) => string }[]—The readings, in the order given. delta is a ratio, e.g. 0.062 for +6.2%.
scrollingbooleanfalseSends the strip through a Marquee, for more items than the row can hold.

Dependencies

Source

components/ui/ticker.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Marquee } from "@/components/ui/marquee"
import { MetricDelta } from "@/components/ui/metric-delta"
import { NumberRoll } from "@/components/ui/number-roll"

const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

export type TickerItem = {
  key: string
  label: string
  value: number
  /** Change since the reading before; a ratio, drawn as a MetricDelta. */
  delta?: number
  /** Formats this item's value only — a price, a duration, a percentage. */
  format?: (n: number) => string
}

export type TickerProps = React.ComponentProps<"div"> & {
  items: TickerItem[]
  /** Scrolls the strip through a Marquee, for more items than the row can hold. */
  scrolling?: boolean
}

/**
 * A strip of label · value · change, the way a market ticker reads.
 *
 * Each value is a NumberRoll, so a new reading rolls to its place rather than
 * cutting to it, and the number a screen reader hears is always the true one
 * rather than a frame of the roll. `scrolling` hands the strip to Marquee, which
 * duplicates it for the loop and stops for hover, focus and reduced motion.
 */
function Ticker({ className, items, scrolling = false, ...props }: TickerProps) {
  const strip = items.map((item) => (
    <div
      key={item.key}
      data-slot="ticker-item"
      className="flex shrink-0 items-baseline gap-2 whitespace-nowrap"
    >
      <span data-slot="ticker-label" className="text-xs text-muted-foreground">
        {item.label}
      </span>
      <NumberRoll
        data-slot="ticker-value"
        value={item.value}
        format={item.format ?? DEFAULT_FORMAT}
        className="text-sm font-medium"
      />
      {item.delta === undefined ? null : (
        <MetricDelta value={item.delta} size="sm" />
      )}
    </div>
  ))

  return (
    <div
      data-slot="ticker"
      data-scrolling={scrolling || undefined}
      className={cn("flex w-full min-w-0 items-center", className)}
      {...props}
    >
      {scrolling ? (
        <Marquee>{strip}</Marquee>
      ) : (
        // Not scrolling, so the row has to survive a narrow card on its own:
        // it scrolls under the reader's own finger instead of on a timer.
        <div
          data-slot="ticker-strip"
          className="flex min-w-0 items-center gap-6 overflow-x-auto"
        >
          {strip}
        </div>
      )}
    </div>
  )
}

export { Ticker }