feat: Add page-level entrance animations using motion library

Subtle fade-in + y-offset animations on mount for all main pages
(initiatives list, initiative detail, agents, inbox) and staggered
card animations for initiative and agent lists.
This commit is contained in:
Lukas May
2026-03-04 07:28:53 +01:00
parent af092ba16a
commit dd86f12057
5 changed files with 173 additions and 96 deletions

View File

@@ -1,4 +1,5 @@
import { AlertCircle, Plus } from "lucide-react"; import { AlertCircle, Plus } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
@@ -83,12 +84,22 @@ export function InitiativeList({
// Populated state // Populated state
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{initiatives.map((initiative) => ( {initiatives.map((initiative, i) => (
<InitiativeCard <motion.div
key={initiative.id} key={initiative.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: Math.min(i * 0.05, 0.3),
ease: [0, 0, 0.2, 1],
}}
>
<InitiativeCard
initiative={initiative} initiative={initiative}
onClick={() => onViewInitiative(initiative.id)} onClick={() => onViewInitiative(initiative.id)}
/> />
</motion.div>
))} ))}
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -182,7 +183,12 @@ function AgentsPage() {
]; ];
return ( return (
<div className="flex h-full flex-col gap-4"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="flex h-full flex-col gap-4"
>
{/* Header + Filters */} {/* Header + Filters */}
<div className="shrink-0 space-y-3"> <div className="shrink-0 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -195,7 +201,12 @@ function AgentsPage() {
Refresh Refresh
</Button> </Button>
</div> </div>
<div className="flex items-center gap-1"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className="flex items-center gap-1"
>
{filterOptions.map((opt) => ( {filterOptions.map((opt) => (
<Button <Button
key={opt.value} key={opt.value}
@@ -213,11 +224,16 @@ function AgentsPage() {
</Badge> </Badge>
</Button> </Button>
))} ))}
</div> </motion.div>
</div> </div>
{/* Two-panel layout */} {/* Two-panel layout */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1"> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1"
>
{/* Left: Agent List */} {/* Left: Agent List */}
<div className="overflow-y-auto min-h-0 space-y-2"> <div className="overflow-y-auto min-h-0 space-y-2">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
@@ -227,9 +243,18 @@ function AgentsPage() {
</p> </p>
</div> </div>
) : ( ) : (
filtered.map((agent) => ( filtered.map((agent, i) => (
<Card <motion.div
key={agent.id} key={agent.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: Math.min(i * 0.05, 0.3),
ease: [0, 0, 0.2, 1],
}}
>
<Card
className={cn( className={cn(
"cursor-pointer p-3 transition-colors hover:bg-muted/50", "cursor-pointer p-3 transition-colors hover:bg-muted/50",
selectedAgentId === agent.id && "bg-muted" selectedAgentId === agent.id && "bg-muted"
@@ -281,6 +306,7 @@ function AgentsPage() {
)} )}
</div> </div>
</Card> </Card>
</motion.div>
)) ))
)} )}
</div> </div>
@@ -302,7 +328,7 @@ function AgentsPage() {
</div> </div>
)} )}
</div> </div>
</div> </motion.div>
</div> </motion.div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -176,10 +177,20 @@ function InboxPage() {
})); }));
return ( return (
<div className="space-y-6"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: Inbox List -- hidden on mobile when agent selected */} {/* Left: Inbox List -- hidden on mobile when agent selected */}
<div className={selectedAgent ? "hidden lg:block" : undefined}> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className={selectedAgent ? "hidden lg:block" : undefined}
>
<InboxList <InboxList
agents={serializedAgents} agents={serializedAgents}
messages={serializedMessages} messages={serializedMessages}
@@ -187,7 +198,7 @@ function InboxPage() {
onSelectAgent={setSelectedAgentId} onSelectAgent={setSelectedAgentId}
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
</div> </motion.div>
{/* Right: Detail Panel */} {/* Right: Detail Panel */}
{selectedAgent && ( {selectedAgent && (
@@ -251,6 +262,6 @@ function InboxPage() {
</div> </div>
)} )}
</div> </div>
</div> </motion.div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
@@ -99,7 +100,12 @@ function InitiativeDetailPage() {
const phases = phasesQuery.data ?? []; const phases = phasesQuery.data ?? [];
return ( return (
<div className="space-y-3"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="space-y-3"
>
{/* Header */} {/* Header */}
<InitiativeHeader <InitiativeHeader
initiative={serializedInitiative} initiative={serializedInitiative}
@@ -108,7 +114,12 @@ function InitiativeDetailPage() {
/> />
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-1 border-b border-border"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05, ease: [0, 0, 0.2, 1] }}
className="flex gap-1 border-b border-border"
>
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab} key={tab}
@@ -127,9 +138,14 @@ function InitiativeDetailPage() {
{tab} {tab}
</button> </button>
))} ))}
</div> </motion.div>
{/* Tab content */} {/* Tab content */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
>
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />} {activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "plan" && ( {activeTab === "plan" && (
<ExecutionTab <ExecutionTab
@@ -149,6 +165,7 @@ function InitiativeDetailPage() {
/> />
)} )}
{activeTab === "review" && <ReviewTab initiativeId={id} />} {activeTab === "review" && <ReviewTab initiativeId={id} />}
</div> </motion.div>
</motion.div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InitiativeList } from "@/components/InitiativeList"; import { InitiativeList } from "@/components/InitiativeList";
@@ -31,7 +32,12 @@ function DashboardPage() {
]); ]);
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: [0, 0, 0.2, 1] }}
className="mx-auto max-w-6xl space-y-6"
>
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Initiatives</h1> <h1 className="text-2xl font-bold">Initiatives</h1>
@@ -57,6 +63,11 @@ function DashboardPage() {
</div> </div>
{/* Initiative list */} {/* Initiative list */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: [0, 0, 0.2, 1] }}
>
<InitiativeList <InitiativeList
statusFilter={statusFilter} statusFilter={statusFilter}
onCreateNew={() => setCreateDialogOpen(true)} onCreateNew={() => setCreateDialogOpen(true)}
@@ -64,12 +75,13 @@ function DashboardPage() {
navigate({ to: "/initiatives/$id", params: { id } }) navigate({ to: "/initiatives/$id", params: { id } })
} }
/> />
</motion.div>
{/* Create initiative dialog */} {/* Create initiative dialog */}
<CreateInitiativeDialog <CreateInitiativeDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
/> />
</div> </motion.div>
); );
} }