diff --git a/frontend/src/components/common/StaggerList.tsx b/frontend/src/components/common/StaggerList.tsx
new file mode 100644
index 00000000..1e3444df
--- /dev/null
+++ b/frontend/src/components/common/StaggerList.tsx
@@ -0,0 +1,43 @@
+import { Children, type ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+interface StaggerListProps {
+ children: ReactNode
+ className?: string
+ /** Base delay before the first item animates (ms). Default: 0 */
+ baseDelay?: number
+ /** Delay increment between each item (ms). Default: 50 */
+ staggerMs?: number
+}
+
+/**
+ * Wraps each child in a stagger-item animation container.
+ * Children fade in sequentially with a configurable delay.
+ *
+ * Usage:
+ *
+ * {items.map(item => )}
+ *
+ */
+export function StaggerList({
+ children,
+ className,
+ baseDelay = 0,
+ staggerMs = 50,
+}: StaggerListProps) {
+ const items = Children.toArray(children)
+
+ return (
+
+ {items.map((child, i) => (
+
+ {child}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index d56ddcfb..61c02c24 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'
-import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
+import { useLocation, useNavigate, Link } from 'react-router-dom'
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
@@ -8,6 +8,7 @@ import { BrandLogo } from '@/components/common/BrandLogo'
import { TopBar } from './TopBar'
import { Sidebar } from './Sidebar'
import { EmailVerificationBanner } from './EmailVerificationBanner'
+import { ViewTransitionOutlet } from './ViewTransitionOutlet'
import { cn } from '@/lib/utils'
export function AppLayout() {
@@ -185,7 +186,7 @@ export function AppLayout() {
{/* Main Content */}
-
+
>
diff --git a/frontend/src/components/layout/ViewTransitionOutlet.tsx b/frontend/src/components/layout/ViewTransitionOutlet.tsx
new file mode 100644
index 00000000..862c2619
--- /dev/null
+++ b/frontend/src/components/layout/ViewTransitionOutlet.tsx
@@ -0,0 +1,21 @@
+import { Outlet, useLocation } from 'react-router-dom'
+
+/**
+ * Wraps with a fade-in animation keyed to the route path.
+ * When the route changes, React unmounts/remounts the wrapper div,
+ * triggering the CSS animation. Sidebar and topbar stay still.
+ */
+export function ViewTransitionOutlet() {
+ const location = useLocation()
+
+ // Use the first two path segments as the key to avoid re-animating
+ // on param changes within the same page (e.g., /trees/123 → /trees/456)
+ const segments = location.pathname.split('/').filter(Boolean)
+ const routeKey = segments.slice(0, 2).join('/') || '/'
+
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx
index 3fb34531..2792c905 100644
--- a/frontend/src/components/library/TreeGridView.tsx
+++ b/frontend/src/components/library/TreeGridView.tsx
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
+import { StaggerList } from '@/components/common/StaggerList'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions'
import { getTreeEditorPath } from '@/lib/routing'
@@ -33,7 +34,7 @@ export function TreeGridView({
const { canEditTree, canDeleteTree } = usePermissions()
return (
-
+
{trees.map((tree) => (
))}
-
+
)
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index bdd0bbe7..360dcb53 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -147,6 +147,11 @@
80% { transform: rotate(-2deg); }
100% { transform: rotate(0deg); }
}
+
+ @keyframes stagger-fade-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
}
/* ── Root CSS variables (non-theme: glass, shadows, layout) ── */
@@ -249,6 +254,12 @@
animation: breatheGlow 3s ease-in-out infinite alternate;
}
+@utility stagger-item {
+ opacity: 0;
+ animation: stagger-fade-in 350ms var(--ease-out-smooth) forwards;
+ animation-delay: calc(var(--stagger-index, 0) * 50ms);
+}
+
@utility rdp-custom {
@apply text-foreground;
& .rdp-month { @apply w-full; }
diff --git a/frontend/src/pages/MyTreesPage.tsx b/frontend/src/pages/MyTreesPage.tsx
index feb8e50b..d65b2094 100644
--- a/frontend/src/pages/MyTreesPage.tsx
+++ b/frontend/src/pages/MyTreesPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
+import { StaggerList } from '@/components/common/StaggerList'
import { Button } from '@/components/ui/Button'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
@@ -217,7 +218,7 @@ export function MyTreesPage() {
) : (
-
+
{trees.map((tree) => (
))}
-
+
)}
{/* Delete Confirmation */}
diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx
index e8471f7e..f205a6be 100644
--- a/frontend/src/pages/SessionHistoryPage.tsx
+++ b/frontend/src/pages/SessionHistoryPage.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
+import { StaggerList } from '@/components/common/StaggerList'
import { sessionsApi } from '@/api/sessions'
import { treesApi } from '@/api/trees'
import type { Session, TreeListItem } from '@/types'
@@ -213,7 +214,8 @@ export function SessionHistoryPage() {
}
/>
) : (
-
+ <>
+
{sessions.map((session) => (
))}
- {hasMore ? (
-
- Showing the 50 most recent sessions
-
- ) : sessions.length > 0 ? (
-
- Showing all {sessions.length} sessions
-
- ) : null}
-
+
+ {hasMore ? (
+
+ Showing the 50 most recent sessions
+
+ ) : sessions.length > 0 ? (
+
+ Showing all {sessions.length} sessions
+
+ ) : null}
+ >
)}
>