diff --git a/CHANGELOG.md b/CHANGELOG.md index b776ad9..38041cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data. - **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait. - **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs. - **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look. diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx index 2b4fe85..1e7ea60 100644 --- a/app/notifications/page.tsx +++ b/app/notifications/page.tsx @@ -16,7 +16,7 @@ import { import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications' import { Button, ArrowLeftIcon } from '@ciphera-net/ui' -import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons' +import { NotificationsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { toast } from '@ciphera-net/ui' const PAGE_SIZE = 50 @@ -31,6 +31,7 @@ export default function NotificationsPage() { const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const showSkeleton = useMinimumLoading(loading) + const fadeClass = useSkeletonFade(showSkeleton) const fetchPage = async (pageOffset: number, append: boolean) => { if (append) setLoadingMore(true) @@ -104,7 +105,7 @@ export default function NotificationsPage() { } return ( -
+
@@ -274,7 +275,7 @@ export default function PublicDashboardPage() { const safeScreenResolutions = screen_resolutions || [] return ( -
+
{/* Header */}
diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 923ade2..655abe3 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation' import { ApiError } from '@/lib/api/client' import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui' -import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons' +import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' import { FunnelChart } from '@/components/ui/funnel-chart' import { getDateRange } from '@ciphera-net/ui' @@ -62,6 +62,7 @@ export default function FunnelReportPage() { } const showSkeleton = useMinimumLoading(loading && !funnel) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return @@ -113,7 +114,7 @@ export default function FunnelReportPage() { })) return ( -
+
diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index 5154de6..2b40d16 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -4,7 +4,7 @@ import { useParams, useRouter } from 'next/navigation' import { deleteFunnel, type Funnel } from '@/lib/api/funnels' import { useFunnels } from '@/lib/swr/dashboard' import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui' -import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons' +import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' export default function FunnelsPage() { @@ -28,13 +28,14 @@ export default function FunnelsPage() { } const showSkeleton = useMinimumLoading(isLoading && !funnels.length) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return } return ( -
+
diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index f636f96..ce95a71 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -6,7 +6,7 @@ import { getDateRange, formatDate } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui' import SankeyDiagram from '@/components/journeys/SankeyDiagram' import TopPathsTable from '@/components/journeys/TopPathsTable' -import { JourneysSkeleton, useMinimumLoading } from '@/components/skeletons' +import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { useDashboard, useJourneyTransitions, @@ -53,6 +53,7 @@ export default function JourneysPage() { }, [dashboard?.site?.domain]) const showSkeleton = useMinimumLoading(transitionsLoading && !transitionsData) + const fadeClass = useSkeletonFade(showSkeleton) const entryPointOptions = [ { value: '', label: 'All entry points' }, @@ -65,7 +66,7 @@ export default function JourneysPage() { if (showSkeleton) return return ( -
+
{/* Header */}
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 50d3072..f7e4ff4 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -23,7 +23,7 @@ import { toast } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import dynamic from 'next/dynamic' -import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' +import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import FilterBar from '@/components/dashboard/FilterBar' import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown' import Chart from '@/components/dashboard/Chart' @@ -423,6 +423,7 @@ export default function SiteDashboardPage() { // Skip the minimum-loading skeleton when SWR already has cached data // (prevents the 300ms flash when navigating back to the dashboard) const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return @@ -437,7 +438,7 @@ export default function SiteDashboardPage() { } return ( -
+
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index dfd0758..4a6a939 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -7,7 +7,7 @@ import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' -import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons' +import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import VerificationModal from '@/components/sites/VerificationModal' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' @@ -509,6 +509,7 @@ export default function SiteSettingsPage() { }, [site?.domain]) const showSkeleton = useMinimumLoading(siteLoading && !site) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return ( @@ -542,7 +543,7 @@ export default function SiteSettingsPage() { } return ( -
+
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index a21c6c8..bbeb81a 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -20,7 +20,7 @@ import { toast } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { Button, Modal } from '@ciphera-net/ui' -import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons' +import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { AreaChart, Area, @@ -645,6 +645,7 @@ export default function UptimePage() { }, [site?.domain]) const showSkeleton = useMinimumLoading(isLoading && !uptimeData) + const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) return if (!site) return
Site not found
@@ -654,7 +655,7 @@ export default function UptimePage() { const overallStatus = uptimeData?.status ?? 'operational' return ( -
+
{/* Header */}
diff --git a/components/skeletons.tsx b/components/skeletons.tsx index b21ac54..8e9483a 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -6,7 +6,7 @@ const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800' -export { useMinimumLoading } from './useMinimumLoading' +export { useMinimumLoading, useSkeletonFade } from './useMinimumLoading' // ─── Primitives ────────────────────────────────────────────── diff --git a/components/useMinimumLoading.ts b/components/useMinimumLoading.ts index a5a86c3..9b64a48 100644 --- a/components/useMinimumLoading.ts +++ b/components/useMinimumLoading.ts @@ -32,3 +32,19 @@ export function useMinimumLoading(loading: boolean, minMs = 300): boolean { return show } + +/** + * Returns 'animate-fade-in' when transitioning from skeleton to content, + * empty string otherwise. Prevents the jarring visual "pop" when skeletons + * are replaced by real content, without adding unnecessary animation when + * data loads from cache (no skeleton shown). + */ +export function useSkeletonFade(showSkeleton: boolean): string { + const wasEverLoading = useRef(false) + + if (showSkeleton) { + wasEverLoading.current = true + } + + return !showSkeleton && wasEverLoading.current ? 'animate-fade-in' : '' +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 650517e..e4b09f6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -23,10 +23,15 @@ const config: Config = { '50%': { backgroundColor: 'var(--highlight)' }, '100%': { backgroundColor: 'transparent' }, }, + 'fade-in': { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, }, animation: { 'cell-highlight': 'cell-highlight 0.5s ease forwards', 'cell-flash': 'cell-flash 0.6s ease forwards', + 'fade-in': 'fade-in 150ms ease-out', }, fontFamily: { sans: ['var(--font-plus-jakarta-sans)', 'system-ui', 'sans-serif'],