feat: add animated number transitions to dashboard stats
Numbers smoothly count up/down when switching date ranges, applying filters, or as real-time visitor count changes. Uses framer-motion useSpring for natural spring physics.
This commit is contained in:
@@ -8,7 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Redesigned Journeys page.** The depth slider now matches the rest of the UI and goes up to 10 steps. Controls are integrated into the chart card for a cleaner layout, and Top Paths are shown as visual breadcrumb cards instead of a cramped table.
|
- **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value.
|
||||||
|
- **Inline bar charts on dashboard lists.** Pages, referrers, locations, technology, and campaigns now show subtle proportional bars behind each row, making it easier to compare values at a glance without reading numbers.
|
||||||
|
- **Redesigned Top Paths.** The Top Paths section on the Journeys page has been completely rebuilt — from bulky cards to a clean, compact list with inline bars that matches the rest of Pulse. Long path sequences are truncated so they stay readable.
|
||||||
|
- **Redesigned Journeys page.** The depth slider now matches the rest of the UI and goes up to 10 steps. Controls are integrated into the chart card for a cleaner layout.
|
||||||
- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s.
|
- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s.
|
||||||
- **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now.
|
- **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now.
|
||||||
- **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data.
|
- **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
|
|||||||
import { Checkbox } from '@ciphera-net/ui'
|
import { Checkbox } from '@ciphera-net/ui'
|
||||||
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
|
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { AnimatedNumber } from '@/components/ui/animated-number'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate'
|
import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate'
|
||||||
|
|
||||||
@@ -350,7 +351,7 @@ export default function Chart({
|
|||||||
>
|
>
|
||||||
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
|
<div className={cn('text-[10px] font-semibold uppercase tracking-widest mb-2', metric === m.key ? 'text-brand-orange' : 'text-neutral-400 dark:text-neutral-500')}>{m.label}</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</span>
|
<AnimatedNumber value={m.value} format={m.format} className="text-2xl font-bold text-neutral-900 dark:text-white" />
|
||||||
{m.change !== null && (
|
{m.change !== null && (
|
||||||
<span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}>
|
<span className={cn('flex items-center gap-0.5 text-sm font-semibold', m.isPositive ? 'text-[#10B981]' : 'text-[#EF4444]')}>
|
||||||
{m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />}
|
{m.isPositive ? <ArrowUpRight weight="bold" className="size-3.5" /> : <ArrowDownRight weight="bold" className="size-3.5" />}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AnimatedNumber } from '@/components/ui/animated-number'
|
||||||
|
|
||||||
interface RealtimeVisitorsProps {
|
interface RealtimeVisitorsProps {
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
@@ -14,7 +18,7 @@ export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
|
|||||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold text-neutral-900 dark:text-white">
|
<div className="text-3xl font-bold text-neutral-900 dark:text-white">
|
||||||
{count}
|
<AnimatedNumber value={count} format={(v) => v.toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
29
components/ui/animated-number.tsx
Normal file
29
components/ui/animated-number.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'
|
||||||
|
|
||||||
|
interface AnimatedNumberProps {
|
||||||
|
value: number
|
||||||
|
format: (v: number) => string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedNumber({ value, format, className }: AnimatedNumberProps) {
|
||||||
|
const motionValue = useMotionValue(value)
|
||||||
|
const spring = useSpring(motionValue, { stiffness: 120, damping: 20, mass: 0.5 })
|
||||||
|
const display = useTransform(spring, (v) => format(Math.round(v)))
|
||||||
|
const isFirst = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirst.current) {
|
||||||
|
// Skip animation on initial render — jump straight to value
|
||||||
|
motionValue.jump(value)
|
||||||
|
isFirst.current = false
|
||||||
|
} else {
|
||||||
|
motionValue.set(value)
|
||||||
|
}
|
||||||
|
}, [value, motionValue])
|
||||||
|
|
||||||
|
return <motion.span className={className}>{display}</motion.span>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user