From ae92923c284a04fb74ec07c51e59bcb5e240d577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 19 Mar 2026 00:48:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=201=20=E2=80=94=20Alive=20Enterp?= =?UTF-8?q?rise=20animation=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animation primitives (6 new components): - AnimatedNumber: count-up with easeOutExpo, de-DE locale formatting - ShimmerSkeleton: diagonal gradient sweep replacing animate-pulse - FadeIn: framer-motion viewport-triggered fade + slide - StaggerList/StaggerItem: staggered children entrance - Sparkline: pure SVG inline trend chart with draw-in animation - ProgressRing: animated circular progress with CSS transitions Sidebar & page transitions: - Sliding nav indicator (framer-motion layoutId animation) - Icon frame hover glow (brand-color shadow) - Smooth section collapse/expand (AnimatePresence height animation) - PageTransition wrapper (fade-up on route change) - AnimatedModal component (scale + fade with custom bezier) - Notification badge bounce on count increase Dashboard animations: - StatCards: AnimatedNumber count-up + staggered FadeIn + budget color tinting - WidgetContainer: fade-slide-up on mount - Chargeability: animated percentages + inline utilization bars - ProjectTable/MyProjects: animated numbers + staggered row entrance Shimmer skeletons & table animations: - Replaced animate-pulse across 20+ loading states with shimmer gradient - Staggered row entrance (fadeSlideIn) on Resources, Projects, Allocations tables - hover-lift utility class for subtle card/row elevation on hover - Content-shaped skeletons (avatars, text bars, badges) Light mode surface depth: - Mesh gradient page background (subtle accent-tinted corners) - Enhanced card shadows (two-layer depth) - Sidebar glassmorphism upgrade (bg-white/60, backdrop-blur-2xl, saturate-150) - Toolbar sticky backdrop blur - Enhanced focus ring with brand-color glow Co-Authored-By: claude-flow --- apps/web/package.json | 1 + .../src/app/(app)/admin/system-roles/page.tsx | 6 +- .../web/src/app/(app)/allocations/loading.tsx | 46 ++--- apps/web/src/app/(app)/allocations/page.tsx | 8 +- apps/web/src/app/(app)/loading.tsx | 12 +- .../src/app/(app)/projects/ProjectsClient.tsx | 7 +- apps/web/src/app/(app)/projects/loading.tsx | 52 +++--- .../app/(app)/resources/ResourcesClient.tsx | 7 +- apps/web/src/app/(app)/resources/loading.tsx | 54 +++--- apps/web/src/app/(app)/resources/page.tsx | 16 +- apps/web/src/app/(app)/timeline/loading.tsx | 30 ++-- apps/web/src/app/(app)/timeline/page.tsx | 8 +- apps/web/src/app/globals.css | 87 ++++++++-- .../components/admin/SystemRolesClient.tsx | 2 +- .../components/admin/SystemSettingsClient.tsx | 4 +- .../allocations/AllocationsClient.tsx | 10 +- .../components/analytics/SkillsAnalytics.tsx | 6 +- .../blueprints/BlueprintsClient.tsx | 2 +- .../components/dashboard/DashboardClient.tsx | 10 +- .../components/dashboard/WidgetContainer.tsx | 9 +- .../dashboard/widgets/ChargeabilityWidget.tsx | 68 +++++--- .../dashboard/widgets/DemandWidget.tsx | 14 +- .../dashboard/widgets/MyProjectsWidget.tsx | 13 +- .../dashboard/widgets/PeakTimesWidget.tsx | 8 +- .../dashboard/widgets/ProjectTableWidget.tsx | 17 +- .../dashboard/widgets/ResourceTableWidget.tsx | 14 +- .../dashboard/widgets/StatCardsWidget.tsx | 60 +++++-- .../dashboard/widgets/TaskWidget.tsx | 6 +- .../dashboard/widgets/TopValueWidget.tsx | 14 +- .../estimates/CommercialTermsEditor.tsx | 2 +- apps/web/src/components/layout/AppShell.tsx | 140 ++++++++++----- .../src/components/layout/PageTransition.tsx | 26 +++ .../BroadcastManagementClient.tsx | 6 +- .../notifications/NotificationBell.tsx | 22 ++- .../NotificationCenterClient.tsx | 6 +- .../components/projects/BudgetStatusCard.tsx | 8 +- .../components/resources/ResourceDetail.tsx | 8 +- apps/web/src/components/ui/AnimatedModal.tsx | 79 +++++++++ apps/web/src/components/ui/AnimatedNumber.tsx | 82 +++++++++ .../src/components/ui/DraggableTableRow.tsx | 3 + apps/web/src/components/ui/FadeIn.tsx | 75 ++++++++ apps/web/src/components/ui/ProgressRing.tsx | 86 ++++++++++ .../web/src/components/ui/ShimmerSkeleton.tsx | 156 +++++++++++++++++ apps/web/src/components/ui/Sparkline.tsx | 162 ++++++++++++++++++ apps/web/src/components/ui/StaggerList.tsx | 83 +++++++++ .../src/components/vacations/BalanceCard.tsx | 6 +- package.json | 5 +- pnpm-lock.yaml | 42 +++++ 48 files changed, 1301 insertions(+), 287 deletions(-) create mode 100644 apps/web/src/components/layout/PageTransition.tsx create mode 100644 apps/web/src/components/ui/AnimatedModal.tsx create mode 100644 apps/web/src/components/ui/AnimatedNumber.tsx create mode 100644 apps/web/src/components/ui/FadeIn.tsx create mode 100644 apps/web/src/components/ui/ProgressRing.tsx create mode 100644 apps/web/src/components/ui/ShimmerSkeleton.tsx create mode 100644 apps/web/src/components/ui/Sparkline.tsx create mode 100644 apps/web/src/components/ui/StaggerList.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 01f61df..4c529a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", "clsx": "^2.1.1", + "framer-motion": "^12.38.0", "next": "^15.1.7", "next-auth": "^5.0.0-beta.25", "react": "^19.0.0", diff --git a/apps/web/src/app/(app)/admin/system-roles/page.tsx b/apps/web/src/app/(app)/admin/system-roles/page.tsx index ff21d62..913370f 100644 --- a/apps/web/src/app/(app)/admin/system-roles/page.tsx +++ b/apps/web/src/app/(app)/admin/system-roles/page.tsx @@ -4,11 +4,11 @@ const SystemRolesClient = dynamic( () => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient), { loading: () => ( -
-
+
+
{[...Array(5)].map((_, i) => ( -
+
))}
diff --git a/apps/web/src/app/(app)/allocations/loading.tsx b/apps/web/src/app/(app)/allocations/loading.tsx index 320fd49..102fbb5 100644 --- a/apps/web/src/app/(app)/allocations/loading.tsx +++ b/apps/web/src/app/(app)/allocations/loading.tsx @@ -1,43 +1,43 @@ export default function AllocationsLoading() { return ( -
+
{/* Header */}
-
-
+
+
{/* Filter bar */}
-
-
-
+
+
+
{/* Table */}
{/* Header */}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{/* Rows */} {[...Array(10)].map((_, i) => ( -
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
))}
diff --git a/apps/web/src/app/(app)/allocations/page.tsx b/apps/web/src/app/(app)/allocations/page.tsx index 3ff485d..d2cd713 100644 --- a/apps/web/src/app/(app)/allocations/page.tsx +++ b/apps/web/src/app/(app)/allocations/page.tsx @@ -4,12 +4,12 @@ const AllocationsClient = dynamic( () => import("~/components/allocations/AllocationsClient.js").then((m) => m.AllocationsClient), { loading: () => ( -
-
-
+
+
+
{[...Array(8)].map((_, i) => ( -
+
))}
diff --git a/apps/web/src/app/(app)/loading.tsx b/apps/web/src/app/(app)/loading.tsx index 1e9d8df..9e332c7 100644 --- a/apps/web/src/app/(app)/loading.tsx +++ b/apps/web/src/app/(app)/loading.tsx @@ -1,15 +1,15 @@ export default function AppLoading() { return ( -
-
-
+
+
+
{[0, 1, 2, 3].map((i) => ( -
+
))}
-
-
+
+
); } diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 20af9d0..772065b 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -530,7 +530,7 @@ export function ProjectsClient() {
{isLoading ? ( -
Loading projects…
+
Loading projects…
) : ( <>
@@ -558,7 +558,7 @@ export function ProjectsClient() { - {projects.map((project) => { + {projects.map((project, index) => { const isSelected = selection.selectedIds.has(project.id); return ( reorder(draggedId, project.id)} - className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} > - {!isCollapsed && ( -
- {section.items.map((item) => ( - - {item.icon} - {item.label} - - ))} -
- )} + + {!isCollapsed && ( + +
+ {section.items.map((item) => { + const isActive = activeHrefSet.has(item.href); + return ( + + {isActive && ( + + )} + {item.icon} + {item.label} + + ); + })} +
+
+ )} +
); })} @@ -327,40 +349,68 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => - {!subCollapsed && ( -
- {entry.items.map((item) => ( - - {item.label} - - ))} -
- )} + + {!subCollapsed && ( + +
+ {entry.items.map((item) => { + const isActive = activeHrefSet.has(item.href); + return ( + + {isActive && ( + + )} + {item.label} + + ); + })} +
+
+ )} +
); } + const isActive = activeHrefSet.has(entry.href); return ( + {isActive && ( + + )} {entry.icon} - {entry.label} + {entry.label} ); })} @@ -430,7 +480,9 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
setChatOpen(true)} /> -
{children}
+
+ {children} +
{chatOpen && setChatOpen(false)} />}
{/* Floating chat FAB — always visible when panel is closed */} diff --git a/apps/web/src/components/layout/PageTransition.tsx b/apps/web/src/components/layout/PageTransition.tsx new file mode 100644 index 0000000..6d343f9 --- /dev/null +++ b/apps/web/src/components/layout/PageTransition.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { motion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +interface PageTransitionProps { + children: ReactNode; + className?: string; +} + +export function PageTransition({ children, className }: PageTransitionProps) { + const pathname = usePathname(); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/notifications/BroadcastManagementClient.tsx b/apps/web/src/components/notifications/BroadcastManagementClient.tsx index 6218566..ac098a9 100644 --- a/apps/web/src/components/notifications/BroadcastManagementClient.tsx +++ b/apps/web/src/components/notifications/BroadcastManagementClient.tsx @@ -72,9 +72,9 @@ export function BroadcastManagementClient() { {isLoading && (
{[...Array(3)].map((_, i) => ( -
-
-
+
+
+
))}
diff --git a/apps/web/src/components/notifications/NotificationBell.tsx b/apps/web/src/components/notifications/NotificationBell.tsx index 4822db5..c223c78 100644 --- a/apps/web/src/components/notifications/NotificationBell.tsx +++ b/apps/web/src/components/notifications/NotificationBell.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { useSession } from "next-auth/react"; import Link from "next/link"; import type { Route } from "next"; +import { motion, useAnimationControls } from "framer-motion"; import { trpc } from "~/lib/trpc/client.js"; function relativeTime(date: Date): string { @@ -34,6 +35,9 @@ export function NotificationBell() { const { data: session, status } = useSession(); const isAuthenticated = status === "authenticated" && !!session?.user?.email; + const badgeControls = useAnimationControls(); + const prevUnreadRef = useRef(null); + const utils = trpc.useUtils(); const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, { @@ -48,6 +52,17 @@ export function NotificationBell() { retry: false, }); + // Bounce badge when unread count increases + useEffect(() => { + if (prevUnreadRef.current !== null && unreadCount > prevUnreadRef.current) { + void badgeControls.start({ + scale: [1, 1.3, 1], + transition: { duration: 0.3, ease: "easeInOut" }, + }); + } + prevUnreadRef.current = unreadCount; + }, [unreadCount, badgeControls]); + const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0); const { data: notifications = [] } = trpc.notification.list.useQuery( @@ -160,9 +175,12 @@ export function NotificationBell() { {/* Unread notification badge (red) */} {unreadCount > 0 && ( - + {unreadCount > 99 ? "99+" : unreadCount} - + )} {/* Task count badge (orange) */} {openTaskCount > 0 && ( diff --git a/apps/web/src/components/notifications/NotificationCenterClient.tsx b/apps/web/src/components/notifications/NotificationCenterClient.tsx index ff087e6..b5f7431 100644 --- a/apps/web/src/components/notifications/NotificationCenterClient.tsx +++ b/apps/web/src/components/notifications/NotificationCenterClient.tsx @@ -190,9 +190,9 @@ export function NotificationCenterClient() { {isLoading && (
{[...Array(5)].map((_, i) => ( -
-
-
+
+
+
))}
diff --git a/apps/web/src/components/projects/BudgetStatusCard.tsx b/apps/web/src/components/projects/BudgetStatusCard.tsx index c92c327..90ed901 100644 --- a/apps/web/src/components/projects/BudgetStatusCard.tsx +++ b/apps/web/src/components/projects/BudgetStatusCard.tsx @@ -57,12 +57,12 @@ export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) { if (isLoading) { return ( -
-
-
+
+
+
{[0, 1, 2, 3].map((i) => ( -
+
))}
diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx index faed6c9..7c1d901 100644 --- a/apps/web/src/components/resources/ResourceDetail.tsx +++ b/apps/web/src/components/resources/ResourceDetail.tsx @@ -109,11 +109,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { if (loadingResource) { return (
-
-
-
+
+
+
- {[0, 1, 2, 3].map((i) =>
)} + {[0, 1, 2, 3].map((i) =>
)}
diff --git a/apps/web/src/components/ui/AnimatedModal.tsx b/apps/web/src/components/ui/AnimatedModal.tsx new file mode 100644 index 0000000..7eb4115 --- /dev/null +++ b/apps/web/src/components/ui/AnimatedModal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { useEffect, useCallback, useRef, type ReactNode } from "react"; + +interface AnimatedModalProps { + open: boolean; + onClose: () => void; + children: ReactNode; + className?: string; + overlayClassName?: string; + maxWidth?: string; +} + +export function AnimatedModal({ + open, + onClose, + children, + className, + overlayClassName, + maxWidth = "max-w-xl", +}: AnimatedModalProps) { + const panelRef = useRef(null); + + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }, + [onClose], + ); + + useEffect(() => { + if (!open) return; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, handleEscape]); + + return ( + + {open && ( +
+ {/* Overlay */} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ui/AnimatedNumber.tsx b/apps/web/src/components/ui/AnimatedNumber.tsx new file mode 100644 index 0000000..e55703a --- /dev/null +++ b/apps/web/src/components/ui/AnimatedNumber.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { memo, useEffect, useRef, useState } from "react"; + +interface AnimatedNumberProps { + value: number; + duration?: number | undefined; + decimals?: number | undefined; + prefix?: string | undefined; + suffix?: string | undefined; + className?: string | undefined; +} + +function easeOutExpo(t: number): number { + return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); +} + +function formatNumber(n: number, decimals: number): string { + return n.toLocaleString("de-DE", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +function AnimatedNumberInner({ + value, + duration = 800, + decimals = 0, + prefix = "", + suffix = "", + className, +}: AnimatedNumberProps) { + const [display, setDisplay] = useState(value); + const prevValueRef = useRef(value); + const rafRef = useRef(null); + + useEffect(() => { + const from = prevValueRef.current; + const to = value; + prevValueRef.current = to; + + if (from === to) { + setDisplay(to); + return; + } + + const startTime = performance.now(); + + function tick(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeOutExpo(progress); + const current = from + (to - from) * eased; + + setDisplay(current); + + if (progress < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + setDisplay(to); + } + } + + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [value, duration]); + + return ( + + {prefix} + {formatNumber(display, decimals)} + {suffix} + + ); +} + +export const AnimatedNumber = memo(AnimatedNumberInner); diff --git a/apps/web/src/components/ui/DraggableTableRow.tsx b/apps/web/src/components/ui/DraggableTableRow.tsx index ef2d7b4..3da39dd 100644 --- a/apps/web/src/components/ui/DraggableTableRow.tsx +++ b/apps/web/src/components/ui/DraggableTableRow.tsx @@ -13,6 +13,7 @@ interface DraggableTableRowProps { onDrop: (draggedId: string) => void; children: React.ReactNode; className?: string; + style?: React.CSSProperties; } /** @@ -27,11 +28,13 @@ export function DraggableTableRow({ onDrop, children, className = "", + style, }: DraggableTableRowProps) { const [isDragOver, setIsDragOver] = useState(false); return ( { e.preventDefault(); diff --git a/apps/web/src/components/ui/FadeIn.tsx b/apps/web/src/components/ui/FadeIn.tsx new file mode 100644 index 0000000..34de6dc --- /dev/null +++ b/apps/web/src/components/ui/FadeIn.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { motion, type Variants } from "framer-motion"; +import { type ReactNode } from "react"; + +interface FadeInProps { + children: ReactNode; + delay?: number; + duration?: number; + direction?: "up" | "down" | "left" | "right" | "none"; + distance?: number; + className?: string; + once?: boolean; +} + +function getOffset( + direction: FadeInProps["direction"], + distance: number, +): { x: number; y: number } { + switch (direction) { + case "up": + return { x: 0, y: distance }; + case "down": + return { x: 0, y: -distance }; + case "left": + return { x: distance, y: 0 }; + case "right": + return { x: -distance, y: 0 }; + case "none": + default: + return { x: 0, y: 0 }; + } +} + +export function FadeIn({ + children, + delay = 0, + duration = 0.3, + direction = "up", + distance = 12, + className, + once = true, +}: FadeInProps) { + const offset = getOffset(direction, distance); + + const variants: Variants = { + hidden: { + opacity: 0, + x: offset.x, + y: offset.y, + }, + visible: { + opacity: 1, + x: 0, + y: 0, + transition: { + duration, + delay, + ease: [0.25, 0.1, 0.25, 1], + }, + }, + }; + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/ui/ProgressRing.tsx b/apps/web/src/components/ui/ProgressRing.tsx new file mode 100644 index 0000000..142c6c4 --- /dev/null +++ b/apps/web/src/components/ui/ProgressRing.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface ProgressRingProps { + value: number; + size?: number; + strokeWidth?: number; + color?: string; + trackColor?: string; + className?: string; + children?: React.ReactNode; + animated?: boolean; +} + +export function ProgressRing({ + value, + size = 40, + strokeWidth = 3, + color = "var(--color-blue-500, #3b82f6)", + trackColor = "var(--color-gray-200, #e5e7eb)", + className, + children, + animated = true, +}: ProgressRingProps) { + const [mounted, setMounted] = useState(!animated); + + useEffect(() => { + if (!animated) return; + // Trigger animation on next frame so the transition fires + const raf = requestAnimationFrame(() => setMounted(true)); + return () => cancelAnimationFrame(raf); + }, [animated]); + + const clamped = Math.max(0, Math.min(100, value)); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (clamped / 100) * circumference; + const center = size / 2; + + return ( +
+ + {/* Track */} + + {/* Progress */} + + + {children != null && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ui/ShimmerSkeleton.tsx b/apps/web/src/components/ui/ShimmerSkeleton.tsx new file mode 100644 index 0000000..1efeeed --- /dev/null +++ b/apps/web/src/components/ui/ShimmerSkeleton.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Children, cloneElement, isValidElement, ReactNode } from "react"; + +/* ------------------------------------------------------------------ */ +/* ShimmerSkeleton */ +/* ------------------------------------------------------------------ */ + +interface ShimmerSkeletonProps { + className?: string; + width?: string | number; + height?: string | number; + rounded?: "sm" | "md" | "lg" | "xl" | "2xl" | "full"; + variant?: "text" | "circle" | "card" | "rect"; +} + +const roundedMap: Record = { + sm: "rounded-sm", + md: "rounded-md", + lg: "rounded-lg", + xl: "rounded-xl", + "2xl": "rounded-2xl", + full: "rounded-full", +}; + +const variantDefaults: Record< + string, + { width?: string | number; height?: string | number; rounded: string } +> = { + text: { width: "100%", height: "1em", rounded: "rounded" }, + circle: { width: 40, height: 40, rounded: "rounded-full" }, + card: { width: "100%", height: 120, rounded: "rounded-xl" }, + rect: { width: "100%", height: 40, rounded: "rounded-md" }, +}; + +export function ShimmerSkeleton({ + className = "", + width, + height, + rounded, + variant = "rect", +}: ShimmerSkeletonProps) { + const defaults = variantDefaults[variant] ?? variantDefaults.rect; + const resolvedWidth = width ?? defaults?.width ?? "100%"; + const resolvedHeight = height ?? defaults?.height ?? 40; + const resolvedRounded = rounded + ? roundedMap[rounded] ?? "rounded-md" + : defaults?.rounded ?? "rounded-md"; + + return ( +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* ShimmerGroup – staggers children entrance by 50ms each */ +/* ------------------------------------------------------------------ */ + +interface ShimmerGroupProps { + children: ReactNode; + staggerMs?: number; + className?: string; +} + +export function ShimmerGroup({ + children, + staggerMs = 50, + className, +}: ShimmerGroupProps) { + return ( +
+ {Children.map(children, (child, index) => { + if (!isValidElement(child)) return child; + return cloneElement(child as React.ReactElement<{ style?: React.CSSProperties }>, { + style: { + ...(child.props as { style?: React.CSSProperties }).style, + animationDelay: `${index * staggerMs}ms`, + }, + }); + })} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Inline