HextaUI

Number flow

Animierte Zahlen, bei denen sich nur die geänderten Ziffern drehen, mit beliebigem Intl-Format und 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

Fügt die Komponente, die HextaUI-Theme-Tokens und alle HextaUI-Komponenten hinzu, von denen sie abhängt.

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

Nur geänderte Ziffern bewegen sich

Jede Ziffer ist eine eigene Spalte. Von 22 auf 23 dreht sich nur die Einerstelle; die Zehnerstelle bleibt stehen. Ziffern, die erscheinen oder verschwinden, wie die dritte Ziffer bei 99 → 100, gleiten mit ihrer Breite ein oder aus.

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

Trend

trend legt fest, in welche Richtung sich Ziffern drehen. auto dreht aufwärts, wenn der Wert wächst, und abwärts, wenn er sinkt, up und down erzwingen eine Richtung, und shortest nimmt den kürzesten Weg um das Rad.

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

Formate

format und locales nehmen beliebige Intl.NumberFormat-Optionen an. Währungssymbole, Trennzeichen und Vorzeichen bleiben stehen, während sich die Ziffern bewegen, und nicht-lateinische Ziffern drehen sich in ihrer eigenen Schrift.

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

Timing

Passe die Bewegung mit duration und easing an oder schalte sie mit animated={false} ab. Einstellungen für reduzierte Bewegung schalten sie automatisch ab.

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

Unterbrechungen und Events

Ein neuer Wert mitten im Drehen läuft von der Stelle weiter, an der jede Ziffer gerade ist, sodass schnelle Updates nie springen. onAnimationsStart und onAnimationsFinish feuern einmal pro Ziffernschub.

"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 und in Badges

Es rendert ein Inline-<span> auf der Textgrundlinie, und tabellarische Ziffern halten seine Breite stabil, während sich Ziffern drehen. BadgeCount nutzt es für Zähler, die an einem Maximum gedeckelt sind.

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

Rechts nach links

Zahlen werden immer von links nach rechts gelesen, auch in rechts-nach-links-Layouts, wie Browser sie darstellen.

"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>
  )
}
  • Screenreader lesen die echte formatierte Zahl vor. Die drehenden Spalten sind für assistive Technik ausgeblendet, und Zeichen, die gerade ausblenden, sind während der Animation versteckt.
  • Es ist keine Live-Region. Umschließe es mit einem Element mit aria-live="polite", wenn Änderungen angesagt werden sollen.
  • Bei aktivierter reduzierter Bewegung werden Werte sofort ohne Drehen aktualisiert.

Akzeptiert alle <span>-Attribute außer children und prefix.

PropTypStandard
valueErforderlich.
number–
locales
Intl.LocalesArgument"en-US"
formatWird an Intl.NumberFormat übergeben.
Intl.NumberFormatOptions–
prefixStatischer Text vor der Zahl.
string–
suffixStatischer Text nach der Zahl.
string–
trend
"auto" | "up" | "down" | "shortest""auto"
durationDauer des Drehens in Millisekunden.
number600
easingBeliebiger CSS-Easing-Wert.
stringeaseSpring
animated
booleantrue
onAnimationsStartWird aufgerufen, wenn ein Schub von Ziffern sich zu bewegen beginnt.
() => void–
onAnimationsFinishWird aufgerufen, wenn der letzte Schub zur Ruhe kommt. Entfällt, wenn ein neuerer Wert ihn unterbricht.
() => void–
AttributBeschreibung
data-slot="number-flow"Das Root-span.
data-slot="number-flow-digit"Jede Ziffernzelle.
data-slot="number-flow-symbol"Trennzeichen, Vorzeichen, Symbole, Präfix und Suffix.
data-slot="number-flow-column"Die drehende Spalte in einer Ziffernzelle.
data-digitDie Ziffer, auf der eine Spalte gerade ruht, 0–9.

In Blocks verwendet

Blocks, die auf Number flow aufbauen.