13-task plan covering shared motion config, dark mode CSS tuning, custom window titlebar, page transitions, stagger animations, shared layout card-to-modal animation, gesture-reactive drag, and micro-interactions across all components.
36 KiB
Motion, Dark Mode & Custom Titlebar Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add playful bouncy Framer Motion animations to every interaction, lighten dark mode for HDR monitors, and implement a custom window titlebar merged into the TopBar.
Architecture: Centralized motion config (src/lib/motion.ts) defines shared spring presets and reusable variants. All components import from this single source. Dark mode is pure CSS variable tuning in index.css. Custom titlebar disables native decorations via Tauri config and embeds window controls into the existing TopBar component.
Tech Stack: Framer Motion 12, React 19, TypeScript, Tauri v2 window API, OKLCH color space, dnd-kit, Tailwind CSS 4
Task 1: Create shared motion config
Files:
- Create:
src/lib/motion.ts
Step 1: Create the motion config file
Create src/lib/motion.ts with spring presets, reusable animation variants, and stagger helper:
import type { Transition, Variants } from "framer-motion";
// --- Spring presets ---
export const springs = {
bouncy: { type: "spring", stiffness: 400, damping: 15, mass: 0.8 } as Transition,
snappy: { type: "spring", stiffness: 500, damping: 20 } as Transition,
gentle: { type: "spring", stiffness: 200, damping: 20 } as Transition,
wobbly: { type: "spring", stiffness: 300, damping: 10 } as Transition,
};
// --- Reusable variants ---
export const fadeSlideUp: Variants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
};
export const fadeSlideDown: Variants = {
hidden: { opacity: 0, y: -12 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 8 },
};
export const fadeSlideLeft: Variants = {
hidden: { opacity: 0, x: 40 },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -40 },
};
export const fadeSlideRight: Variants = {
hidden: { opacity: 0, x: -40 },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 40 },
};
export const scaleIn: Variants = {
hidden: { opacity: 0, scale: 0.9 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
};
// --- Stagger container ---
export function staggerContainer(staggerDelay = 0.04): Variants {
return {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay,
},
},
};
}
// --- Micro-interaction presets ---
export const microInteraction = {
hover: { scale: 1.05 },
tap: { scale: 0.95 },
};
export const subtleHover = {
hover: { scale: 1.02 },
tap: { scale: 0.98 },
};
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit
Expected: No errors from src/lib/motion.ts
Step 3: Commit
git add src/lib/motion.ts
git commit -m "feat: add shared motion config with spring presets and variants"
Task 2: Update dark mode CSS variables
Files:
- Modify:
src/index.css:101-140(the.darkblock)
Step 1: Update the .dark block in src/index.css
Replace the entire .dark { ... } block (lines 101-140) with these lightened values:
.dark {
--background: oklch(0.22 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.27 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.27 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.32 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.32 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.27 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0);
--pylon-bg: oklch(25% 0.012 50);
--pylon-surface: oklch(29% 0.012 50);
--pylon-column: oklch(27% 0.014 50);
--pylon-accent: oklch(62% 0.13 160);
--pylon-text: oklch(92% 0.01 50);
--pylon-text-secondary: oklch(58% 0.01 50);
--pylon-danger: oklch(62% 0.18 25);
}
Step 2: Visual test
Run: npm run tauri dev
Toggle dark mode in Settings. Verify backgrounds are noticeably lighter (charcoal, not cave-black). Colors should still feel warm.
Step 3: Commit
git add src/index.css
git commit -m "feat: lighten dark mode for HDR monitors — bump pylon + shadcn values"
Task 3: Custom window titlebar — Tauri config + WindowControls component
Files:
- Modify:
src-tauri/tauri.conf.json:13-19(windows config) - Create:
src/components/layout/WindowControls.tsx - Modify:
src/components/layout/TopBar.tsx
Step 1: Disable native decorations in tauri.conf.json
In src-tauri/tauri.conf.json, add "decorations": false to the window config object:
"windows": [
{
"title": "OpenPylon",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"decorations": false
}
]
Step 2: Create WindowControls component
Create src/components/layout/WindowControls.tsx:
import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { motion } from "framer-motion";
import { Minus, Square, Copy, X } from "lucide-react";
import { springs } from "@/lib/motion";
export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
const appWindow = getCurrentWindow();
// Check initial state
appWindow.isMaximized().then(setIsMaximized);
// Listen for resize events to track maximize state
const unlisten = appWindow.onResized(async () => {
const maximized = await appWindow.isMaximized();
setIsMaximized(maximized);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const appWindow = getCurrentWindow();
return (
<div className="flex items-center">
{/* Separator */}
<div className="mx-2 h-4 w-px bg-pylon-text-secondary/20" />
{/* Minimize */}
<motion.button
onClick={() => appWindow.minimize()}
className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-colors hover:bg-pylon-accent/10 hover:text-pylon-text"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
transition={springs.snappy}
aria-label="Minimize"
>
<Minus className="size-4" />
</motion.button>
{/* Maximize / Restore */}
<motion.button
onClick={() => appWindow.toggleMaximize()}
className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-colors hover:bg-pylon-accent/10 hover:text-pylon-text"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
transition={springs.snappy}
aria-label={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
<Copy className="size-3.5" />
) : (
<Square className="size-3.5" />
)}
</motion.button>
{/* Close */}
<motion.button
onClick={() => appWindow.close()}
className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-colors hover:bg-pylon-danger/15 hover:text-pylon-danger"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
transition={springs.snappy}
aria-label="Close"
>
<X className="size-4" />
</motion.button>
</div>
);
}
Step 3: Integrate WindowControls into TopBar
In src/components/layout/TopBar.tsx, add this import at the top:
import { WindowControls } from "@/components/layout/WindowControls";
Then, at the very end of the right section <div> (after the Settings tooltip, before the closing </div> on line 241), add:
<WindowControls />
Step 4: Test
Run: npm run tauri dev
Verify: Native titlebar is gone. Custom minimize/maximize/close buttons appear in TopBar right side. Window dragging still works via the header. All three buttons function correctly.
Step 5: Commit
git add src-tauri/tauri.conf.json src/components/layout/WindowControls.tsx src/components/layout/TopBar.tsx
git commit -m "feat: custom window titlebar — remove native decorations, add WindowControls to TopBar"
Task 4: Page transitions in App.tsx
Files:
- Modify:
src/App.tsx
Step 1: Add AnimatePresence page transitions
Replace the imports and the view rendering section in src/App.tsx.
Add to imports:
import { AnimatePresence, motion } from "framer-motion";
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";
Replace line 76 (the view conditional):
{view.type === "board-list" ? <BoardList /> : <BoardView />}
With:
<AnimatePresence mode="wait">
{view.type === "board-list" ? (
<motion.div
key="board-list"
className="h-full"
variants={fadeSlideRight}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.gentle}
>
<BoardList />
</motion.div>
) : (
<motion.div
key={`board-${view.boardId}`}
className="h-full"
variants={fadeSlideLeft}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.gentle}
>
<BoardView />
</motion.div>
)}
</AnimatePresence>
Step 2: Test
Run: npm run tauri dev
Navigate between board list and board view. Verify smooth slide transitions with gentle spring.
Step 3: Commit
git add src/App.tsx
git commit -m "feat: add AnimatePresence page transitions between views"
Task 5: Board list stagger animations
Files:
- Modify:
src/components/boards/BoardList.tsx - Modify:
src/components/boards/BoardCard.tsx
Step 1: Add stagger container to BoardList
In src/components/boards/BoardList.tsx, add imports:
import { motion } from "framer-motion";
import { staggerContainer, fadeSlideUp, springs, scaleIn } from "@/lib/motion";
Replace the empty state content (lines 27-44, the <div className="flex h-full flex-col items-center justify-center gap-6"> block) — wrap the inner content with motion for fade-in:
<motion.div
className="flex h-full flex-col items-center justify-center gap-6"
variants={scaleIn}
initial="hidden"
animate="visible"
transition={springs.gentle}
>
<div className="text-center">
<h2 className="font-heading text-2xl text-pylon-text">
Welcome to OpenPylon
</h2>
<p className="mt-2 max-w-sm text-sm text-pylon-text-secondary">
A local-first Kanban board that keeps your data on your machine.
Create your first board to get started.
</p>
</div>
<div className="flex items-center gap-3">
<Button size="lg" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Create Board
</Button>
<ImportExportButtons />
</div>
</motion.div>
Replace the board grid <div> (line 68):
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
With a motion.div stagger container:
<motion.div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
Step 2: Update BoardCard to use shared variants
In src/components/boards/BoardCard.tsx, add imports:
import { fadeSlideUp, springs, subtleHover } from "@/lib/motion";
Replace the existing <motion.div> wrapper (lines 76-80):
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25, delay: index * 0.05 }}
>
With:
<motion.div
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
transition={springs.bouncy}
whileHover={subtleHover.hover}
whileTap={subtleHover.tap}
>
Remove the index prop from the BoardCardProps interface and function signature — stagger is now handled by the parent container. Also remove the index = 0 default parameter.
Step 3: Commit
git add src/components/boards/BoardList.tsx src/components/boards/BoardCard.tsx
git commit -m "feat: add stagger animations to board list and board cards"
Task 6: Board view — column stagger + card stagger
Files:
- Modify:
src/components/board/BoardView.tsx - Modify:
src/components/board/KanbanColumn.tsx - Modify:
src/components/board/CardThumbnail.tsx
Step 1: Add column stagger to BoardView
In src/components/board/BoardView.tsx, add imports:
import { motion } from "framer-motion";
import { staggerContainer, springs } from "@/lib/motion";
Wrap the column list container (line 323, the <div className="flex h-full overflow-x-auto" ...>) — replace the <div> with <motion.div>:
<motion.div
className="flex h-full overflow-x-auto"
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
variants={staggerContainer(0.06)}
initial="hidden"
animate="visible"
>
Also change the closing </div> (line 378) to </motion.div>.
Step 2: Update KanbanColumn to use shared config
In src/components/board/KanbanColumn.tsx, update the import:
import { motion, useReducedMotion } from "framer-motion";
Add:
import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion";
Replace the <motion.section> props (lines 66-74):
<motion.section
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
initial={prefersReducedMotion ? false : { opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
{...attributes}
>
With:
<motion.section
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
transition={springs.bouncy}
layout
{...attributes}
>
Wrap the <ul> card list (line 87) with stagger. Replace the <ul>:
<ul ref={setDroppableNodeRef} className="flex min-h-[40px] list-none flex-col" style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}>
With a motion.ul:
<motion.ul
ref={setDroppableNodeRef}
className="flex min-h-[40px] list-none flex-col"
style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}
variants={staggerContainer(0.03)}
initial="hidden"
animate="visible"
>
Change the closing </ul> to </motion.ul>.
Step 3: Update CardThumbnail to use shared config
In src/components/board/CardThumbnail.tsx, add imports:
import { fadeSlideUp, springs, subtleHover } from "@/lib/motion";
Replace the <motion.button> props (lines 49-56):
<motion.button
ref={setNodeRef}
style={style}
onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md"
initial={prefersReducedMotion ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
With:
<motion.button
ref={setNodeRef}
style={style}
onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
whileTap={{ scale: 0.98 }}
transition={springs.bouncy}
layout
Remove the Tailwind hover classes hover:-translate-y-px hover:shadow-md since Framer Motion handles hover now. Also remove transition-all duration-200.
Step 4: Commit
git add src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx
git commit -m "feat: add column stagger + card stagger + card hover/tap animations"
Task 7: Card detail modal — shared layout animation
Files:
- Modify:
src/components/board/CardThumbnail.tsx - Modify:
src/components/card-detail/CardDetailModal.tsx - Modify:
src/components/board/BoardView.tsx
Step 1: Add layoutId to CardThumbnail
In src/components/board/CardThumbnail.tsx, add layoutId to the <motion.button>:
After the layout prop, add:
layoutId={`card-${card.id}`}
Step 2: Replace Radix Dialog with custom Framer Motion modal in CardDetailModal
This is the most complex change. We replace the Radix <Dialog> with a custom modal using Framer Motion's AnimatePresence and motion.div with a matching layoutId.
Rewrite src/components/card-detail/CardDetailModal.tsx. Replace the entire Dialog-based return block (lines 31-93) with:
import { AnimatePresence, motion } from "framer-motion";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
Add these imports at the top of the file (alongside existing imports). Then replace the return (...) block:
return (
<AnimatePresence>
{open && card && cardId && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<motion.div
layoutId={`card-${cardId}`}
className="relative w-full max-w-3xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
transition={springs.gentle}
onClick={(e) => e.stopPropagation()}
>
{/* Close on Escape */}
<EscapeHandler onClose={onClose} />
{/* Hidden accessible description */}
<span className="sr-only">Card detail editor</span>
<motion.div
className="flex max-h-[80vh] flex-col sm:flex-row"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{/* Left panel: Title + Markdown (60%) */}
<motion.div
className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<div className="mb-4">
<InlineTitle
cardId={cardId}
title={card.title}
updateCard={updateCard}
/>
</div>
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Vertical separator */}
<Separator orientation="vertical" className="hidden sm:block" />
{/* Right sidebar (40%) */}
<motion.div
className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
<Separator />
<LabelPicker
cardId={cardId}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
<Separator />
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
<Separator />
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
<Separator />
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</motion.div>
</motion.div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
Add a small EscapeHandler component at the bottom of the file:
function EscapeHandler({ onClose }: { onClose: () => void }) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return null;
}
Remove the Radix Dialog imports that are no longer needed:
// REMOVE these imports:
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
Keep DialogTitle if used by InlineTitle — actually InlineTitle uses DialogTitle on line 155. Replace that with a plain <h2>:
In the InlineTitle component, change:
<DialogTitle
onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent"
>
{title}
</DialogTitle>
To:
<h2
onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent"
>
{title}
</h2>
Step 3: Wrap the CardDetailModal in the parent with proper context
In src/components/board/BoardView.tsx, the <CardDetailModal> is already rendered outside the <DndContext>. No changes needed — AnimatePresence is now inside CardDetailModal itself.
Step 4: Test
Run: npm run tauri dev
Click a card. It should morph/expand from its position into the modal. The backdrop should blur in. Close should reverse the animation. Content sections should stagger in.
Step 5: Commit
git add src/components/board/CardThumbnail.tsx src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: shared layout animation — card expands into detail modal"
Task 8: Gesture-reactive drag overlay
Files:
- Modify:
src/components/board/DragOverlayContent.tsx - Modify:
src/components/board/BoardView.tsx
Step 1: Create motion-powered drag overlay
Rewrite src/components/board/DragOverlayContent.tsx to use Framer Motion with gesture-reactive tilt:
import { useRef } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
import type { Card, Column, Label } from "@/types/board";
import { LabelDots } from "@/components/board/LabelDots";
import { ChecklistBar } from "@/components/board/ChecklistBar";
import { format, isPast, isToday } from "date-fns";
import { springs } from "@/lib/motion";
interface CardOverlayProps {
card: Card;
boardLabels: Label[];
}
export function CardOverlay({ card, boardLabels }: CardOverlayProps) {
const hasDueDate = card.dueDate != null;
const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
const rotate = useMotionValue(0);
const scale = useMotionValue(1.05);
// Track pointer movement for tilt
const lastX = useRef(0);
return (
<motion.div
className="w-[260px] cursor-grabbing rounded-lg bg-pylon-surface p-3 shadow-xl"
style={{ rotate, scale }}
initial={{ scale: 1, rotate: 0 }}
animate={{ scale: 1.05, boxShadow: "0 20px 40px oklch(0% 0 0 / 20%)" }}
exit={{ scale: 1, rotate: 0 }}
transition={springs.bouncy}
onPointerMove={(e) => {
const deltaX = e.clientX - lastX.current;
lastX.current = e.clientX;
// Tilt based on horizontal velocity, clamped to ±5 degrees
const tilt = Math.max(-5, Math.min(5, deltaX * 0.3));
animate(rotate, tilt, { type: "spring", stiffness: 300, damping: 20 });
}}
>
{/* Cover color bar */}
{card.coverColor && (
<div
className="mb-2 -mx-3 -mt-3 h-1 rounded-t-lg"
style={{ backgroundColor: `oklch(55% 0.12 ${card.coverColor})` }}
/>
)}
{/* Label dots */}
{card.labels.length > 0 && (
<div className="mb-2">
<LabelDots labelIds={card.labels} boardLabels={boardLabels} />
</div>
)}
{/* Card title */}
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
{/* Footer row */}
{(hasDueDate || card.checklist.length > 0) && (
<div className="mt-2 flex items-center gap-3">
{dueDate && (
<span
className={`font-mono text-xs ${
overdue
? "rounded bg-pylon-danger/10 px-1 py-0.5 text-pylon-danger"
: "text-pylon-text-secondary"
}`}
>
{format(dueDate, "MMM d")}
</span>
)}
{card.checklist.length > 0 && (
<ChecklistBar checklist={card.checklist} />
)}
</div>
)}
</motion.div>
);
}
interface ColumnOverlayProps {
column: Column;
}
export function ColumnOverlay({ column }: ColumnOverlayProps) {
return (
<motion.div
className="w-[280px] cursor-grabbing rounded-lg bg-pylon-column p-3 shadow-xl"
initial={{ scale: 1, rotate: 0 }}
animate={{ scale: 1.03, rotate: 1, boxShadow: "0 20px 40px oklch(0% 0 0 / 20%)" }}
exit={{ scale: 1, rotate: 0 }}
transition={springs.bouncy}
>
<div className="flex items-center gap-2 border-b border-border pb-2">
<span className="truncate font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{column.title}
</span>
<span className="shrink-0 font-mono text-xs text-pylon-text-secondary">
{column.cardIds.length}
</span>
</div>
<div className="mt-2 space-y-1">
{column.cardIds.slice(0, 3).map((_, i) => (
<div key={i} className="h-6 rounded bg-pylon-surface/50" />
))}
{column.cardIds.length > 3 && (
<p className="text-center font-mono text-xs text-pylon-text-secondary">
+{column.cardIds.length - 3} more
</p>
)}
</div>
</motion.div>
);
}
Step 2: Wrap DragOverlay in AnimatePresence
In src/components/board/BoardView.tsx, add AnimatePresence import:
import { AnimatePresence } from "framer-motion";
Wrap the <DragOverlay> contents with AnimatePresence. Replace lines 382-388:
<DragOverlay>
{activeCard ? (
<CardOverlay card={activeCard} boardLabels={board.labels} />
) : activeColumn ? (
<ColumnOverlay column={activeColumn} />
) : null}
</DragOverlay>
With:
<DragOverlay dropAnimation={null}>
<AnimatePresence>
{activeCard ? (
<CardOverlay key="card-overlay" card={activeCard} boardLabels={board.labels} />
) : activeColumn ? (
<ColumnOverlay key="column-overlay" column={activeColumn} />
) : null}
</AnimatePresence>
</DragOverlay>
Note: dropAnimation={null} disables dnd-kit's built-in drop animation since we handle it with Framer Motion.
Step 3: Commit
git add src/components/board/DragOverlayContent.tsx src/components/board/BoardView.tsx
git commit -m "feat: gesture-reactive drag overlay with tilt based on pointer velocity"
Task 9: TopBar micro-interactions
Files:
- Modify:
src/components/layout/TopBar.tsx
Step 1: Add motion to TopBar buttons and saving status
In src/components/layout/TopBar.tsx, add imports:
import { motion, AnimatePresence } from "framer-motion";
import { springs } from "@/lib/motion";
Wrap each icon <Button> in the right section with motion micro-interactions. Since the <Button> component uses asChild pattern with Radix Tooltip, we wrap the <Button> itself. The simplest approach: convert each bare <Button> to a motion.div wrapper with whileHover and whileTap.
Replace the saving status span (lines 201-204):
{savingStatus && (
<span className="mr-2 font-mono text-xs text-pylon-text-secondary">
{savingStatus}
</span>
)}
With AnimatePresence for fade in/out:
<AnimatePresence mode="wait">
{savingStatus && (
<motion.span
key={savingStatus}
className="mr-2 font-mono text-xs text-pylon-text-secondary"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={springs.snappy}
>
{savingStatus}
</motion.span>
)}
</AnimatePresence>
Step 2: Commit
git add src/components/layout/TopBar.tsx
git commit -m "feat: add micro-animations to TopBar saving status"
Task 10: Toast animations — wobbly spring
Files:
- Modify:
src/components/toast/ToastContainer.tsx
Step 1: Update toast spring to wobbly preset
In src/components/toast/ToastContainer.tsx, add import:
import { springs } from "@/lib/motion";
Replace the <motion.div> transition (line 22):
transition={{ type: "spring", stiffness: 400, damping: 25 }}
With:
transition={springs.wobbly}
Also update the exit to slide down:
exit={{ opacity: 0, y: 20, scale: 0.9 }}
Step 2: Commit
git add src/components/toast/ToastContainer.tsx
git commit -m "feat: use wobbly spring for toast notifications"
Task 11: Settings dialog — tab crossfade + swatch hover
Files:
- Modify:
src/components/settings/SettingsDialog.tsx
Step 1: Add AnimatePresence for tab content crossfade
In src/components/settings/SettingsDialog.tsx, add imports:
import { motion, AnimatePresence } from "framer-motion";
import { springs, scaleIn, microInteraction } from "@/lib/motion";
Wrap the tab content section (line 123, <div className="flex flex-col gap-5 pt-1">) with AnimatePresence:
<AnimatePresence mode="wait">
<motion.div
key={tab}
className="flex flex-col gap-5 pt-1"
variants={scaleIn}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.snappy}
>
{/* ... existing tab content unchanged ... */}
</motion.div>
</AnimatePresence>
For the accent color swatch buttons (line 196-210), add motion.button with hover animation. Replace each <button> in the ACCENT_PRESETS map:
<motion.button
key={hue}
type="button"
onClick={() => setAccentColor(hue)}
className="size-7 rounded-full"
style={{
backgroundColor: bg,
outline: settings.accentColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "2px",
}}
whileHover={microInteraction.hover}
whileTap={microInteraction.tap}
transition={springs.snappy}
aria-label={label}
title={label}
/>
Remove the transition-transform hover:scale-110 Tailwind classes since Framer Motion handles it.
Step 2: Commit
git add src/components/settings/SettingsDialog.tsx
git commit -m "feat: add tab crossfade and swatch hover animations to settings"
Task 12: Shortcut help modal entrance animation
Files:
- Modify:
src/components/shortcuts/ShortcutHelpModal.tsx
Step 1: Add entrance animation
In src/components/shortcuts/ShortcutHelpModal.tsx, add imports:
import { motion, AnimatePresence } from "framer-motion";
import { springs, scaleIn, staggerContainer, fadeSlideUp } from "@/lib/motion";
The current implementation uses Radix Dialog which handles its own open/close. We can add motion to the content inside. Replace the <div className="flex flex-col gap-4"> (line 45) with a motion stagger container:
<motion.div
className="flex flex-col gap-4"
variants={staggerContainer(0.06)}
initial="hidden"
animate="visible"
>
{SHORTCUT_GROUPS.map((group) => (
<motion.div key={group.category} variants={fadeSlideUp} transition={springs.bouncy}>
<h4 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{group.category}
</h4>
<div className="flex flex-col gap-1">
{group.shortcuts.map(({ key, description }) => (
<div key={key} className="flex items-center justify-between py-1">
<span className="text-sm text-pylon-text">{description}</span>
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
{key}
</kbd>
</div>
))}
</div>
</motion.div>
))}
</motion.div>
Step 2: Commit
git add src/components/shortcuts/ShortcutHelpModal.tsx
git commit -m "feat: add stagger entrance animation to shortcut help modal"
Task 13: Polish pass — verify reduced-motion + TypeScript check
Files:
- Modify:
src/lib/motion.ts(if needed) - Verify all files compile
Step 1: Run TypeScript check
Run: npx tsc --noEmit
Expected: No errors. Fix any type issues that arise.
Step 2: Test reduced-motion
In the browser dev tools, run: document.documentElement.style.setProperty('prefers-reduced-motion', 'reduce') or use the browser's rendering tools to emulate reduced-motion.
Verify: All Framer Motion animations respect useReducedMotion() where it's already used (KanbanColumn, CardThumbnail, BoardCard). The CSS prefers-reduced-motion media query in index.css already handles CSS transitions.
Step 3: Visual test all animations
Run: npm run tauri dev
Test checklist:
- Dark mode is lighter, warm tones preserved
- Custom titlebar buttons work (minimize, maximize, close)
- Window dragging works via TopBar
- Page transitions slide between board list and board view
- Board cards stagger in on the board list
- Columns stagger in when opening a board
- Cards stagger in within columns
- Card hover scales up slightly with shadow
- Card tap scales down
- Clicking card morphs into detail modal (shared layout animation)
- Closing modal morphs back to card position
- Drag overlay lifts card with scale + shadow
- Dragging card tilts based on movement direction
- Toast notifications bounce in with wobbly spring
- Settings tab switching crossfades
- Accent color swatches bounce on hover
- Shortcut help groups stagger in
- Saving status fades in/out in TopBar
Step 4: Commit any polish fixes
git add -A
git commit -m "fix: polish pass — animation tweaks and reduced-motion compliance"
Quick Reference: File → Task Mapping
| File | Tasks |
|---|---|
src/lib/motion.ts |
1 (create) |
src/index.css |
2 |
src-tauri/tauri.conf.json |
3 |
src/components/layout/WindowControls.tsx |
3 (create) |
src/components/layout/TopBar.tsx |
3, 9 |
src/App.tsx |
4 |
src/components/boards/BoardList.tsx |
5 |
src/components/boards/BoardCard.tsx |
5 |
src/components/board/BoardView.tsx |
6, 8 |
src/components/board/KanbanColumn.tsx |
6 |
src/components/board/CardThumbnail.tsx |
6, 7 |
src/components/card-detail/CardDetailModal.tsx |
7 |
src/components/board/DragOverlayContent.tsx |
8 |
src/components/toast/ToastContainer.tsx |
10 |
src/components/settings/SettingsDialog.tsx |
11 |
src/components/shortcuts/ShortcutHelpModal.tsx |
12 |