HextaUI

Number flow

Números animados em que só os dígitos alterados giram, com qualquer formato Intl e locale.

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

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele depende.

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

Só os dígitos alterados se movem

Cada dígito é sua própria coluna. Ir de 22 para 23 gira apenas o dígito das unidades; o das dezenas fica parado. Dígitos que aparecem ou somem, como o terceiro dígito em 99 → 100, deslizam sua largura para dentro ou para fora.

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

Tendência

trend define para que lado os dígitos giram. auto gira para cima quando o valor cresce e para baixo quando diminui, up e down forçam uma direção, e shortest toma o caminho mais curto pela roda.

"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 e locales aceitam qualquer opção de Intl.NumberFormat. Símbolos de moeda, separadores e sinais ficam parados enquanto os dígitos se movem, e numerais não latinos giram na própria escrita.

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

Tempo

Ajuste o movimento com duration e easing, ou desligue com animated={false}. As configurações de movimento reduzido o desligam automaticamente.

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

Interrupções e eventos

Um novo valor no meio do giro continua de onde cada dígito está, então atualizações rápidas nunca saltam. onAnimationsStart e onAnimationsFinish disparam uma 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>
  )
}

Inline e em badges

Renderiza um <span> inline que fica na linha de base do texto, e dígitos tabulares mantêm sua largura estável enquanto os dígitos giram. BadgeCount o usa para contagens limitadas a um 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>
  )
}

Da direita para a esquerda

Os números sempre são lidos da esquerda para a direita, mesmo em um layout da direita para a esquerda, como os navegadores os renderizam.

"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>
  )
}
  • Os leitores de tela leem o número formatado real. As colunas giratórias ficam ocultas das tecnologias assistivas, e os caracteres que estão saindo ficam ocultos enquanto animam a saída.
  • Não é uma live region. Envolva-o em um elemento com aria-live="polite" quando as mudanças devem ser anunciadas.
  • Com movimento reduzido ativado, os valores se atualizam instantaneamente, sem giro.

Aceita todos os atributos de <span> exceto children e prefix.

PropTipoPadrão
valueObrigatório.
number–
locales
Intl.LocalesArgument"en-US"
formatRepassado ao Intl.NumberFormat.
Intl.NumberFormatOptions–
prefixTexto estático antes do número.
string–
suffixTexto estático depois do número.
string–
trend
"auto" | "up" | "down" | "shortest""auto"
durationDuração do giro em milissegundos.
number600
easingQualquer valor de easing CSS.
stringeaseSpring
animated
booleantrue
onAnimationsStartChamado quando um lote de dígitos começa a se mover.
() => void–
onAnimationsFinishChamado quando o último lote termina. Ignorado se um valor mais novo o interrompe.
() => void–
AtributoDescrição
data-slot="number-flow"O span raiz.
data-slot="number-flow-digit"Cada célula de dígito.
data-slot="number-flow-symbol"Separadores, sinais, símbolos, prefixo e sufixo.
data-slot="number-flow-column"A coluna giratória dentro de uma célula de dígito.
data-digitO dígito em que uma coluna está parada, 0–9.

Usado em blocos

Blocos que se baseiam em Number flow.