diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx
index fa76498..2b4fe85 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 } from '@/components/skeletons'
+import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import { toast } from '@ciphera-net/ui'
const PAGE_SIZE = 50
@@ -30,6 +30,7 @@ export default function NotificationsPage() {
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
+ const showSkeleton = useMinimumLoading(loading)
const fetchPage = async (pageOffset: number, append: boolean) => {
if (append) setLoadingMore(true)
@@ -128,7 +129,7 @@ export default function NotificationsPage() {
- {loading ? (
+ {showSkeleton ? (
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx
index a79272f..7a1b04f 100644
--- a/app/share/[id]/page.tsx
+++ b/app/share/[id]/page.tsx
@@ -13,7 +13,7 @@ import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
import PerformanceStats from '@/components/dashboard/PerformanceStats'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
-import { DashboardSkeleton } from '@/components/skeletons'
+import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
// Helper to get date ranges
@@ -193,7 +193,9 @@ export default function PublicDashboardPage() {
loadDashboard()
}
- if (loading && !data && !isPasswordProtected) {
+ const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
+
+ if (showSkeleton) {
return
}
diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx
index 29446ab..7bcd8ea 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, useTheme, Button } from '@ciphera-net/ui'
-import { FunnelDetailSkeleton } from '@/components/skeletons'
+import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
import {
BarChart,
@@ -92,7 +92,9 @@ export default function FunnelReportPage() {
}
}
- if (loading && !funnel) {
+ const showSkeleton = useMinimumLoading(loading && !funnel)
+
+ if (showSkeleton) {
return
}
diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx
index 20b32c3..e1ab641 100644
--- a/app/sites/[id]/funnels/page.tsx
+++ b/app/sites/[id]/funnels/page.tsx
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
-import { FunnelsListSkeleton } from '@/components/skeletons'
+import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link'
export default function FunnelsPage() {
@@ -44,7 +44,9 @@ export default function FunnelsPage() {
}
}
- if (loading) {
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) {
return
}
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index e24b7f8..274b7ec 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -11,7 +11,7 @@ import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
-import { DashboardSkeleton } from '@/components/skeletons'
+import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
@@ -216,7 +216,9 @@ export default function SiteDashboardPage() {
return () => clearInterval(interval)
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
- if (loading) {
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) {
return
}
diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx
index 63ba754..a8ed5ca 100644
--- a/app/sites/[id]/realtime/page.tsx
+++ b/app/sites/[id]/realtime/page.tsx
@@ -7,7 +7,7 @@ import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { UserIcon } from '@ciphera-net/ui'
-import { RealtimeSkeleton, SessionEventsSkeleton } from '@/components/skeletons'
+import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
@@ -91,7 +91,9 @@ export default function RealtimePage() {
}
}
- if (loading) return
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) return
if (!site) return
Site not found
return (
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index d09377d..5b10014 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -6,7 +6,7 @@ import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoData
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
-import { SettingsFormSkeleton, GoalsListSkeleton } from '@/components/skeletons'
+import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import { PasswordInput } from '@ciphera-net/ui'
@@ -317,7 +317,9 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000)
}
- if (loading) {
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) {
return (
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx
index af3f943..d746d93 100644
--- a/app/sites/[id]/uptime/page.tsx
+++ b/app/sites/[id]/uptime/page.tsx
@@ -21,7 +21,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 } from '@/components/skeletons'
+import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import {
AreaChart,
Area,
@@ -703,7 +703,9 @@ export default function UptimePage() {
setShowEditModal(true)
}
- if (loading) return
+ const showSkeleton = useMinimumLoading(loading)
+
+ if (showSkeleton) return
if (!site) return
Site not found
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
diff --git a/components/skeletons.tsx b/components/skeletons.tsx
index b1872c6..bf33111 100644
--- a/components/skeletons.tsx
+++ b/components/skeletons.tsx
@@ -6,6 +6,8 @@
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
+export { useMinimumLoading } from './useMinimumLoading'
+
// ─── Primitives ──────────────────────────────────────────────
export function SkeletonLine({ className = '' }: { className?: string }) {
diff --git a/components/useMinimumLoading.ts b/components/useMinimumLoading.ts
new file mode 100644
index 0000000..a5a86c3
--- /dev/null
+++ b/components/useMinimumLoading.ts
@@ -0,0 +1,34 @@
+'use client'
+
+import { useState, useEffect, useRef } from 'react'
+
+/**
+ * Prevents skeleton flicker on fast loads by keeping it visible
+ * for at least `minMs` once it appears.
+ *
+ * @param loading - The raw loading state from data fetching
+ * @param minMs - Minimum milliseconds the skeleton stays visible (default 300)
+ * @returns Whether the skeleton should be shown
+ */
+export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
+ const [show, setShow] = useState(loading)
+ const startRef = useRef
(loading ? Date.now() : 0)
+
+ useEffect(() => {
+ if (loading) {
+ startRef.current = Date.now()
+ setShow(true)
+ } else {
+ const elapsed = Date.now() - startRef.current
+ const remaining = minMs - elapsed
+ if (remaining > 0) {
+ const timer = setTimeout(() => setShow(false), remaining)
+ return () => clearTimeout(timer)
+ } else {
+ setShow(false)
+ }
+ }
+ }, [loading, minMs])
+
+ return show
+}