Input
Preview
Code
import NumberFlow from '@number-flow/react'
import clsx from 'clsx/lite'
import { Minus, Plus } from 'lucide-react'
import * as React from 'react'
type Props = {
value?: number
min?: number
max?: number
onChange?: (value: number) => void
}
export default function Input({ value = 0, min = -Infinity, max = Infinity, onChange }: Props) {
const defaultValue = React.useRef(value)
const inputRef = React.useRef<HTMLInputElement>(null)
const [animated, setAnimated] = React.useState(true)
// Hide the caret during transitions so you can't see it shifting around:
const [showCaret, setShowCaret] = React.useState(true)
const handleInput: React.InputEventHandler<HTMLInputElement> = ({ currentTarget: el }) => {
setAnimated(false)
let next = value
if (el.value === '') {
next = defaultValue.current
} else {
const num = el.valueAsNumber
if (!isNaN(num) && min <= num && num <= max) next = num
}
// Manually update the input.value in case the number stays the same e.g. 09 == 9
el.value = String(next)
onChange?.(next)
}
const handlePointerDown = (diff: number) => (event: React.PointerEvent<HTMLButtonElement>) => {
setAnimated(true)
if (event.pointerType === 'mouse') {
event?.preventDefault()
inputRef.current?.focus()
}
const newVal = Math.min(Math.max(value + diff, min), max)
onChange?.(newVal)
}
return (
<div className="group flex items-stretch rounded-md text-3xl font-semibold ring ring-zinc-200 transition-[box-shadow] focus-within:ring-2 focus-within:ring-blue-500 dark:ring-zinc-800">
<button
aria-hidden="true"
tabIndex={-1}
className="flex items-center pl-[.5em] pr-[.325em]"
disabled={min != null && value <= min}
onPointerDown={handlePointerDown(-1)}
>
<Minus className="size-4" absoluteStrokeWidth strokeWidth={3.5} />
</button>
<div className="relative grid items-center justify-items-center text-center [grid-template-areas:'overlap'] *:[grid-area:overlap]">
<input
ref={inputRef}
className={clsx(
showCaret ? 'caret-primary' : 'caret-transparent',
'spin-hide w-[1.5em] bg-transparent py-2 text-center font-[inherit] text-transparent outline-none'
)}
// Make sure to disable kerning, to match NumberFlow:
style={{ fontKerning: 'none' }}
type="number"
min={min}
step={1}
autoComplete="off"
inputMode="numeric"
max={max}
value={value}
onInput={handleInput}
/>
<NumberFlow
value={value}
locales="en-US"
format={{ useGrouping: false }}
aria-hidden="true"
animated={animated}
onAnimationsStart={() => setShowCaret(false)}
onAnimationsFinish={() => setShowCaret(true)}
className="pointer-events-none"
willChange
/>
</div>
<button
aria-hidden="true"
tabIndex={-1}
className="flex items-center pl-[.325em] pr-[.5em]"
disabled={max != null && value >= max}
onPointerDown={handlePointerDown(1)}
>
<Plus className="size-4" absoluteStrokeWidth strokeWidth={3.5} />
</button>
</div>
)
}Activity
Preview
Code
import NumberFlow, { continuous, type Format } from '@number-flow/react'
import clsx from 'clsx/lite'
import { Bookmark, ChartNoAxesColumn, Heart, Repeat, Share } from 'lucide-react'
import type { ComponentPropsWithoutRef } from 'react'
const format: Format = {
notation: 'compact',
compactDisplay: 'short',
roundingMode: 'trunc'
}
type Props = ComponentPropsWithoutRef<'div'> & {
likes: number
reposts: number
views: number
bookmarks: number
liked: boolean
reposted: boolean
bookmarked: boolean
onLike: () => void
onBookmark: () => void
onRepost: () => void
}
export default function Activity({
className,
likes,
reposts,
views,
bookmarks,
onLike,
onRepost,
onBookmark,
liked,
reposted,
bookmarked,
...rest
}: Props) {
return (
<div
{...rest}
className={clsx(
className,
'flex w-full select-none items-center text-zinc-600 dark:text-zinc-300'
)}
>
<div className="flex flex-1 items-center gap-1.5">
<ChartNoAxesColumn absoluteStrokeWidth className="~size-4/5" />
<NumberFlow
willChange
plugins={[continuous]}
value={views}
locales="en-US"
format={format}
/>
</div>
<div className="flex-1">
<button
className={clsx(
'group flex items-center gap-1.5 pr-1.5 transition-[color] hover:text-emerald-500',
reposted && 'text-emerald-500'
)}
onClick={onRepost}
>
<div className="relative before:absolute before:-inset-2.5 before:rounded-full before:transition-[background-color] before:group-hover:bg-emerald-500/10">
<Repeat
absoluteStrokeWidth
className="~size-4/5 group-active:spring-duration-[25] spring-bounce-50 spring-duration-300 transition-transform group-active:scale-[85%]"
/>
</div>
<NumberFlow
willChange
plugins={[continuous]}
value={reposts}
locales="en-US"
format={format}
/>
</button>
</div>
<div className="flex-1">
<button
className={clsx(
'group flex items-center gap-1.5 pr-1.5 transition-[color] hover:text-pink-500',
liked && 'text-pink-500'
)}
onClick={onLike}
>
<div className="relative before:absolute before:-inset-2.5 before:rounded-full before:transition-[background-color] before:group-hover:bg-pink-500/10">
<Heart
absoluteStrokeWidth
className={clsx(
'~size-4/5 group-active:spring-duration-[25] spring-bounce-[65] spring-duration-300 transition-transform group-active:scale-[80%]',
liked && 'fill-current'
)}
/>
</div>
<NumberFlow
willChange
plugins={[continuous]}
value={likes}
locales="en-US"
format={format}
/>
</button>
</div>
<div className="min-[30rem]:flex-1 max-[24rem]:hidden flex shrink-0 items-center gap-1.5">
<button
className={clsx(
'group flex items-center gap-1.5 pr-1.5 transition-[color] hover:text-blue-500',
bookmarked && 'text-blue-500'
)}
onClick={onBookmark}
>
<div className="relative before:absolute before:-inset-2.5 before:rounded-full before:transition-[background-color] before:group-hover:bg-blue-500/10">
<Bookmark
absoluteStrokeWidth
className={clsx(
'~size-4/5 group-active:spring-duration-[25] spring-bounce-50 spring-duration-300 transition-transform group-active:scale-[85%]',
bookmarked && 'fill-current'
)}
/>
</div>
<NumberFlow
className="max-[30rem]:hidden"
willChange
plugins={[continuous]}
value={bookmarks}
locales="en-US"
format={format}
/>
</button>
</div>
<Share absoluteStrokeWidth className="~size-4/5 shrink-0" />
</div>
)
}Countdown
Preview
Code
import NumberFlow, { NumberFlowGroup } from '@number-flow/react'
type Props = {
seconds: number
}
export default function Countdown({ seconds }: Props) {
const hh = Math.floor(seconds / 3600)
const mm = Math.floor((seconds % 3600) / 60)
const ss = seconds % 60
return (
<NumberFlowGroup>
<div
style={{ fontVariantNumeric: 'tabular-nums' }}
className="~text-3xl/4xl flex items-baseline font-semibold"
>
<NumberFlow trend={-1} value={hh} format={{ minimumIntegerDigits: 2 }} />
<NumberFlow
prefix=":"
trend={-1}
value={mm}
digits={{ 1: { max: 5 } }}
format={{ minimumIntegerDigits: 2 }}
/>
<NumberFlow
prefix=":"
trend={-1}
value={ss}
digits={{ 1: { max: 5 } }}
format={{ minimumIntegerDigits: 2 }}
/>
</div>
</NumberFlowGroup>
)
}Motion for React
NumberFlow was designed to work with { type: 'spring', duration: 0.9, bounce: 0 } transition to match NumberFlow’s default transform timing:
Preview
Code
import { motion, MotionConfig } from 'motion/react'
import NumberFlow, { useCanAnimate } from '@number-flow/react'
import { ArrowUp } from 'lucide-react'
import clsx from 'clsx/lite'
import type { CSSProperties } from 'react'
const MotionNumberFlow = motion.create(NumberFlow)
const MotionArrowUp = motion.create(ArrowUp)
type Props = {
value: number
}
export default function MotionExample({ value }: Props) {
const canAnimate = useCanAnimate()
return (
<MotionConfig
// Disable layout animations if NumberFlow can't animate.
// This worked better than setting layout={canAnimate}
transition={{
layout: canAnimate ? { duration: 0.9, bounce: 0, type: 'spring' } : { duration: 0 }
}}
>
<motion.span
className={clsx(
value > 0 ? 'bg-emerald-400' : 'bg-red-500',
'inline-flex items-center px-[0.3em] text-2xl text-white transition-colors duration-300'
)}
layout
style={{ borderRadius: 999 }}
>
<MotionArrowUp
className="mr-0.5 size-[0.75em]"
absoluteStrokeWidth
strokeWidth={3}
layout // undo parent
transition={{
rotate: canAnimate ? { type: 'spring', duration: 0.5, bounce: 0 } : { duration: 0 }
}}
animate={{ rotate: value > 0 ? 0 : -180 }}
initial={false}
/>
<MotionNumberFlow
value={value}
className="font-semibold"
format={{ style: 'percent', maximumFractionDigits: 2 }}
style={{ '--number-flow-mask-height': '0.3em' } as CSSProperties}
// Important, see note below:
layout
layoutRoot
/>
</motion.span>
</MotionConfig>
)
}When NumberFlow is used within a layout animation it should be (or be in) a
motion componentlayout layoutRoot props.