Input
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.ChangeEventHandler<HTMLInputElement> = ({ currentTarget: el }) => {
setAnimated(false)
let next = value
if (el.value === '') {
next = defaultValue.current
} else {
const num = parseInt(el.value)
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}
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
import NumberFlow, { type Format } from '@number-flow/react'
import clsx from 'clsx/lite'
import { Bookmark, ChartNoAxesColumn, Heart, Repeat, Share } from 'lucide-react'
const format: Format = {
notation: 'compact',
compactDisplay: 'short',
roundingMode: 'trunc'
}
type Props = JSX.IntrinsicElements['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,
'~text-[0.8125rem]/sm 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 continuous value={views} 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 continuous value={reposts} 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 continuous value={likes} format={format} />
</button>
</div>
<div className="flex flex-1 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 willChange continuous value={bookmarks} format={format} />
</button>
</div>
<Share absoluteStrokeWidth className="~size-4/5 shrink-0" />
</div>
)
}
Slider
import NumberFlow from '@number-flow/react'
import * as RadixSlider from '@radix-ui/react-slider'
import clsx from 'clsx/lite'
export default function Slider({ value, className, ...props }: RadixSlider.SliderProps) {
return (
<RadixSlider.Root
{...props}
value={value}
className={clsx(className, 'relative flex h-5 w-[200px] touch-none select-none items-center')}
>
<RadixSlider.Track className="relative h-[3px] grow rounded-full bg-zinc-100 dark:bg-zinc-800">
<RadixSlider.Range className="absolute h-full rounded-full bg-black dark:bg-white" />
</RadixSlider.Track>
<RadixSlider.Thumb
className="relative block h-5 w-5 rounded-[1rem] bg-white shadow-md ring ring-black/10"
aria-label="Volume"
>
{value?.[0] != null && (
<NumberFlow
willChange
value={value[0]}
isolate
continuous
opacityTiming={{
duration: 250,
easing: 'ease-out'
}}
transformTiming={{
easing: `linear(0, 0.0033 0.8%, 0.0263 2.39%, 0.0896 4.77%, 0.4676 15.12%, 0.5688, 0.6553, 0.7274, 0.7862, 0.8336 31.04%, 0.8793, 0.9132 38.99%, 0.9421 43.77%, 0.9642 49.34%, 0.9796 55.71%, 0.9893 62.87%, 0.9952 71.62%, 0.9983 82.76%, 0.9996 99.47%)`,
duration: 500
}}
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-lg font-semibold"
/>
)}
</RadixSlider.Thumb>
</RadixSlider.Root>
)
}
Countdown
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', '--number-flow-char-height': '0.85em' }}
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:
import { motion, MotionConfig } from 'motion/react'
import NumberFlow, { useCanAnimate } from '@number-flow/react'
import { ArrowUp } from 'lucide-react'
import clsx from 'clsx/lite'
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-char-height': '0.85em', '--number-flow-mask-height': '0.3em' }}
// 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.