HextaUI

Number flow

Números animados donde solo giran los dígitos que cambian, con cualquier formato Intl y configuración regional.

22
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

export function NumberFlowDemo() {
  const [value, setValue] = React.useState(22)

  return (
    <div className="flex flex-col items-center gap-6">
      <NumberFlow
        value={value}
        className="text-5xl font-semibold tracking-tight"
      />
      <div className="flex flex-wrap justify-center gap-2">
        <Button variant="outline" size="sm" onClick={() => setValue(value - 1)}>
          −1
        </Button>
        <Button variant="outline" size="sm" onClick={() => setValue(value + 1)}>
          +1
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(value + 111)}
        >
          +111
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(Math.floor(Math.random() * 100000))}
        >
          Random
        </Button>
      </div>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/number-flow.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

import { NumberFlow } from "@/components/ui/number-flow"
<NumberFlow
  value={1234.5}
  format={{ style: "currency", currency: "USD" }}
/>

Solo se mueven los dígitos que cambian

Cada dígito es su propia columna. Pasar de 22 a 23 solo hace girar el dígito de las unidades; el de las decenas permanece quieto. Los dígitos que aparecen o desaparecen, como el tercer dígito en 99 → 100, deslizan su ancho hacia dentro o hacia fuera.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

const pairs = [
  [22, 23],
  [14, 19],
  [99, 100],
  [19, 21],
]

export function NumberFlowChangedDigits() {
  const [value, setValue] = React.useState(22)

  return (
    <div className="flex flex-col items-center gap-6">
      <NumberFlow value={value} className="text-5xl font-semibold" />
      <div className="flex flex-wrap justify-center gap-2">
        {pairs.map(([from, to]) => (
          <Button
            key={`${from}-${to}`}
            variant="outline"
            size="sm"
            onClick={() => setValue(value === from ? to : from)}
          >
            {from} ↔ {to}
          </Button>
        ))}
      </div>
    </div>
  )
}

Tendencia

trend define hacia dónde giran los dígitos. auto gira hacia arriba cuando el valor crece y hacia abajo cuando disminuye, up y down fuerzan una dirección, y shortest toma el camino más corto alrededor de la rueda.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow, type NumberFlowTrend } from "@/components/ui/number-flow"

const trends: NumberFlowTrend[] = ["auto", "up", "down", "shortest"]

export function NumberFlowTrendDemo() {
  const [value, setValue] = React.useState(19)

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
        {trends.map((trend) => (
          <div
            key={trend}
            className="flex flex-col gap-1 rounded-lg border p-3"
          >
            <span className="text-xs text-muted-foreground">{trend}</span>
            <NumberFlow
              value={value}
              trend={trend}
              className="text-2xl font-semibold"
            />
          </div>
        ))}
      </div>
      <div className="flex flex-wrap gap-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(value === 19 ? 21 : 19)}
        >
          19 ↔ 21
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(value === 19 ? 11 : 19)}
        >
          19 ↔ 11
        </Button>
      </div>
    </div>
  )
}

Formatos

format y locales aceptan cualquier opción de Intl.NumberFormat. Los símbolos de moneda, separadores y signos permanecen quietos mientras se mueven los dígitos, y los numerales no latinos giran en su propia escritura.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

const factors = [1, 1.37, 0.62, 2.91]

export function NumberFlowFormats() {
  const [step, setStep] = React.useState(0)
  const factor = factors[step % factors.length]

  const rows = [
    {
      label: "Currency",
      value: (
        <NumberFlow
          value={1234.56 * factor}
          format={{ style: "currency", currency: "USD" }}
        />
      ),
    },
    {
      label: "Percent",
      value: (
        <NumberFlow
          value={0.4213 * factor}
          format={{ style: "percent", maximumFractionDigits: 1 }}
        />
      ),
    },
    {
      label: "Compact",
      value: (
        <NumberFlow
          value={1234567 * factor}
          format={{ notation: "compact", maximumFractionDigits: 1 }}
        />
      ),
    },
    {
      label: "Fixed decimals",
      value: (
        <NumberFlow
          value={3.14159 * factor}
          format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
        />
      ),
    },
    {
      label: "de-DE",
      value: <NumberFlow value={9876543.21 * factor} locales="de-DE" />,
    },
    {
      label: "Signed",
      value: (
        <NumberFlow
          value={(factor - 1) * 100}
          format={{ signDisplay: "exceptZero", maximumFractionDigits: 0 }}
          suffix="%"
        />
      ),
    },
    {
      label: "ar-EG",
      value: (
        <NumberFlow
          value={4821 * factor}
          locales="ar-EG"
          format={{ maximumFractionDigits: 0 }}
        />
      ),
    },
    {
      label: "Prefix and suffix",
      value: (
        <NumberFlow
          value={Math.round(1840 * factor)}
          prefix="~"
          suffix=" users"
        />
      ),
    },
  ]

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <dl className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
        {rows.map((row) => (
          <div
            key={row.label}
            className="flex items-baseline justify-between gap-4 border-b py-1.5"
          >
            <dt className="text-muted-foreground">{row.label}</dt>
            <dd className="font-medium">{row.value}</dd>
          </div>
        ))}
      </dl>
      <Button
        variant="outline"
        size="sm"
        className="self-start"
        onClick={() => setStep(step + 1)}
      >
        Change values
      </Button>
    </div>
  )
}

Tiempos

Ajusta el movimiento con duration y easing, o desactívalo con animated={false}. Los ajustes de movimiento reducido lo desactivan automáticamente.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

export function NumberFlowTiming() {
  const [value, setValue] = React.useState(128)

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
        <div className="flex flex-col gap-1 rounded-lg border p-3">
          <span className="text-xs text-muted-foreground">200ms</span>
          <NumberFlow
            value={value}
            duration={200}
            className="text-2xl font-semibold"
          />
        </div>
        <div className="flex flex-col gap-1 rounded-lg border p-3">
          <span className="text-xs text-muted-foreground">600ms</span>
          <NumberFlow value={value} className="text-2xl font-semibold" />
        </div>
        <div className="flex flex-col gap-1 rounded-lg border p-3">
          <span className="text-xs text-muted-foreground">1200ms</span>
          <NumberFlow
            value={value}
            duration={1200}
            easing="ease-in-out"
            className="text-2xl font-semibold"
          />
        </div>
        <div className="flex flex-col gap-1 rounded-lg border p-3">
          <span className="text-xs text-muted-foreground">Off</span>
          <NumberFlow
            value={value}
            animated={false}
            className="text-2xl font-semibold"
          />
        </div>
      </div>
      <Button
        variant="outline"
        size="sm"
        className="self-start"
        onClick={() => setValue(value + 37)}
      >
        +37
      </Button>
    </div>
  )
}

Interrupciones y eventos

Un valor nuevo a mitad del giro continúa desde donde está cada dígito, así que las actualizaciones rápidas nunca saltan. onAnimationsStart y onAnimationsFinish se disparan una vez por lote de dígitos.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

export function NumberFlowEvents() {
  const [value, setValue] = React.useState(1200)
  const [status, setStatus] = React.useState("Idle")
  const timers = React.useRef<number[]>([])

  React.useEffect(() => {
    const pending = timers.current
    return () => pending.forEach((id) => window.clearTimeout(id))
  }, [])

  function burst() {
    timers.current.forEach((id) => window.clearTimeout(id))
    timers.current = Array.from({ length: 10 }, (_, index) =>
      window.setTimeout(
        () => setValue((current) => current + Math.ceil(Math.random() * 40)),
        index * 90
      )
    )
  }

  return (
    <div className="flex flex-col items-center gap-4">
      <NumberFlow
        value={value}
        onAnimationsStart={() => setStatus("Animating")}
        onAnimationsFinish={() => setStatus("Settled")}
        className="text-5xl font-semibold"
      />
      <span className="text-sm text-muted-foreground">{status}</span>
      <Button variant="outline" size="sm" onClick={burst}>
        Burst ×10
      </Button>
    </div>
  )
}

En línea y en badges

Renderiza un <span> en línea que se sitúa sobre la línea base del texto, y los dígitos tabulares mantienen su ancho estable mientras giran. BadgeCount lo usa para recuentos que se limitan a un máximo.

"use client"

import * as React from "react"
import { IconInbox } from "@tabler/icons-react"

import { Badge, BadgeCount } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

export function NumberFlowInline() {
  const [value, setValue] = React.useState(8)

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <p className="text-sm">
        You have <NumberFlow value={value} className="font-semibold" /> new
        messages and the baseline stays aligned with the text.
      </p>
      <div className="flex flex-wrap items-center gap-2">
        <Button variant="outline">
          <IconInbox />
          Inbox
          <Badge size="sm" appearance="solid" variant="destructive">
            <BadgeCount value={value} />
          </Badge>
        </Button>
        <Badge>
          <BadgeCount value={value * 7} max={999} />
          unread
        </Badge>
        <Button variant="outline" size="sm" onClick={() => setValue(value + 1)}>
          +1
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(value + 50)}
        >
          +50
        </Button>
      </div>
    </div>
  )
}

De derecha a izquierda

Los números siempre se leen de izquierda a derecha, incluso dentro de un diseño de derecha a izquierda, igual que los renderizan los navegadores.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"

export function NumberFlowRtl() {
  const [value, setValue] = React.useState(1250)

  return (
    <div dir="rtl" className="flex flex-col items-center gap-4">
      <NumberFlow
        value={value}
        locales="ar-EG"
        className="text-3xl font-semibold"
      />
      <Button variant="outline" size="sm" onClick={() => setValue(value + 17)}>
        ١٧+
      </Button>
    </div>
  )
}
  • Los lectores de pantalla leen el número real con formato. Las columnas que giran se ocultan a las tecnologías de asistencia, y los caracteres que se van se ocultan mientras se animan hacia fuera.
  • No es una región live. Envuélvelo en un elemento con aria-live="polite" cuando los cambios deban anunciarse.
  • Con movimiento reducido activado, los valores se actualizan al instante sin giro.

Acepta todos los atributos de <span> excepto children y prefix.

PropTipoPredeterminado
valueObligatorio.
number–
locales
Intl.LocalesArgument"en-US"
formatSe pasa a Intl.NumberFormat.
Intl.NumberFormatOptions–
prefixTexto estático antes del número.
string–
suffixTexto estático después del número.
string–
trend
"auto" | "up" | "down" | "shortest""auto"
durationDuración del giro en milisegundos.
number600
easingCualquier valor de easing de CSS.
stringeaseSpring
animated
booleantrue
onAnimationsStartSe llama cuando un lote de dígitos empieza a moverse.
() => void–
onAnimationsFinishSe llama cuando el último lote se asienta. Se omite si un valor más reciente lo interrumpe.
() => void–
AtributoDescripción
data-slot="number-flow"El span raíz.
data-slot="number-flow-digit"Cada celda de dígito.
data-slot="number-flow-symbol"Separadores, signos, símbolos, prefijo y sufijo.
data-slot="number-flow-column"La columna giratoria dentro de una celda de dígito.
data-digitEl dígito sobre el que reposa una columna, 0–9.

Usado en bloques

Bloques que se construyen sobre Number flow.