style: add fade-in transition from skeleton to content

Smooth out the jarring visual pop when loading skeletons are replaced
by real content. Only animates after an actual skeleton was shown —
cached data still renders instantly with no delay.
This commit is contained in:
Usman Baig
2026-03-13 12:45:48 +01:00
parent 0abc5cd4a8
commit 8c4bb8f861
12 changed files with 47 additions and 17 deletions

View File

@@ -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 <FunnelDetailSkeleton />
@@ -113,7 +114,7 @@ export default function FunnelReportPage() {
}))
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">

View File

@@ -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 <FunnelsListSkeleton />
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div>

View File

@@ -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 <JourneysSkeleton />
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>

View File

@@ -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 <DashboardSkeleton />
@@ -437,7 +438,7 @@ export default function SiteDashboardPage() {
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">

View File

@@ -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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="space-y-8">
<div>

View File

@@ -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 <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
@@ -654,7 +655,7 @@ export default function UptimePage() {
const overallStatus = uptimeData?.status ?? 'operational'
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>