Motion Design for Web Apps: Tasteful Animations with Framer Motion
Animation in web apps is like seasoning in cooking. The right amount elevates everything. Too much ruins the dish. And the worst offense is animation that exists for its own sake — bouncing elements, gratuitous parallax, entrance effects on every component. That is not design. That is a demo reel.
We have built a lot of interfaces at Threshline — from MindHyv’s business dashboard to Trackelio’s feedback management views — and the pattern is always the same: the best animations are the ones users do not consciously notice. They just feel that the app is responsive, polished, and alive.
Framer Motion (now called Motion) is our go-to library for React-based animation. It has an API that feels natural, handles layout animations that would be nightmarish with CSS alone, and compiles to reasonable bundle sizes. Here is how we use it.
The Principles: When to Animate
Before writing any animation code, ask three questions:
Does this animation communicate state change? A modal sliding in, a toast appearing, a list item being removed — these animations tell the user what happened. The element did not just appear or disappear. It arrived from somewhere and departed to somewhere. This gives users a spatial model of the interface.
Does this animation reduce perceived latency? A skeleton loader that pulses, a progress bar that fills, a button that transitions to a loading spinner — these animations make waiting feel shorter. If something takes 800ms, a smooth transition makes it feel like 400ms.
Does this animation provide feedback? A button that scales down on press, a card that lifts on hover, an input that shakes on invalid submission — these animations confirm that the interface registered the user’s action.
If the answer to all three questions is “no,” skip the animation. A static transition is not a failure. It is a design choice.

Page Transitions
Page transitions are the highest-impact animation you can add to a web app. They transform navigation from a jarring full-page replacement into a smooth spatial experience.
Here is a basic fade-and-slide transition using AnimatePresence:
import { AnimatePresence, motion } from "motion/react";
import { useLocation } from "react-router-dom";
function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
A few things to note:
mode="wait"ensures the exiting page finishes its animation before the entering page starts. Without this, both pages are visible simultaneously during the transition — it looks broken.- The
yoffset is subtle: 8 pixels. This is intentional. A large offset (like 50px) feels like a presentation slide. A small offset feels like a natural flow. - Duration is 200ms. Anything above 300ms starts to feel sluggish for navigation. Users are impatient. Respect that.
For apps with a sidebar navigation, you often want the sidebar to stay fixed while only the content area transitions. Scope the AnimatePresence to the content container, not the entire layout:
function AppLayout() {
const location = useLocation();
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="p-6"
>
<Outlet />
</motion.div>
</AnimatePresence>
</main>
</div>
);
}
List Animations
Lists are where animation makes the biggest UX difference. Adding, removing, and reordering items without animation is disorienting — the user cannot track what changed. With animation, each change is visually narrated.
import { AnimatePresence, motion } from "motion/react";
interface Task {
id: string;
title: string;
done: boolean;
}
function TaskList({ tasks, onRemove }: {
tasks: Task[];
onRemove: (id: string) => void;
}) {
return (
<ul className="space-y-2">
<AnimatePresence initial={false}>
{tasks.map((task) => (
<motion.li
key={task.id}
layout
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{
opacity: { duration: 0.15 },
height: { duration: 0.2 },
layout: { type: "spring", stiffness: 500, damping: 35 },
}}
className="overflow-hidden"
>
<div className="flex items-center justify-between p-3 bg-neutral-900 rounded-lg">
<span className={task.done ? "line-through text-neutral-500" : ""}>
{task.title}
</span>
<button
onClick={() => onRemove(task.id)}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
The layout prop is doing the heavy lifting here. When items are added or removed, the remaining items smoothly shift to their new positions. Without it, they would jump instantly — and the user would lose context about what moved where.
The initial={false} on AnimatePresence prevents the entrance animation from firing on the initial render. You only want the animation when items are dynamically added, not when the page first loads and the entire list animates in one by one. That is a common mistake that makes lists feel slow.
Modal and Dialog Animations
Modals are a prime candidate for animation because they represent a clear state change: something is overlaid on top of the current view.
import { AnimatePresence, motion } from "motion/react";
function Modal({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 z-40"
/>
{/* Modal content */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div className="bg-neutral-900 rounded-xl p-6 max-w-lg w-full shadow-2xl">
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
We use a spring transition for the modal content and a linear fade for the backdrop. The spring gives the modal a physical feeling — it has weight and momentum. The backdrop just needs to appear and disappear without drawing attention to itself.
The scale starts at 0.95, not 0.5 or 0. Subtle scale changes feel natural. Large scale changes feel like a PowerPoint transition from 2005.
A common gotcha with AnimatePresence and modals: the exit animation will not work if the modal component is conditionally rendered at a level above AnimatePresence. The isOpen check must happen inside AnimatePresence’s children, as shown above.
Scroll-Triggered Animations
Elements that animate as they enter the viewport can add polish to marketing pages and dashboards. Framer Motion’s whileInView makes this straightforward:
function FeatureCard({ title, description }: {
title: string;
description: string;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="p-6 bg-neutral-900 rounded-xl"
>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-2 text-neutral-400">{description}</p>
</motion.div>
);
}
Two critical details:
-
viewport={{ once: true }}means the animation fires once and stays. Without this, elements animate every time they scroll in and out of view — it looks fidgety and distracting. There are rare cases where you want repeating animations (live dashboards, infinite scroll feeds), but for most content, once is correct. -
margin: "-100px"triggers the animation when the element is 100px inside the viewport, not exactly at the edge. This means the animation starts before the element reaches the visible area, so the user sees the animated state rather than catching it mid-transition.
Use scroll animations sparingly. A landing page with 12 sections that all fade in as you scroll becomes tedious. Pick the 3-4 most important elements and animate those. Let the rest be static.

Gesture Interactions
Gestures — hover, tap, drag — make interfaces feel physical. Framer Motion’s gesture props make these trivial to implement.
Interactive card with hover lift:
function ProjectCard({ project }: { project: Project }) {
return (
<motion.div
whileHover={{ y: -4, boxShadow: "0 8px 30px rgba(0,0,0,0.3)" }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="p-5 bg-neutral-900 rounded-xl cursor-pointer"
>
<h3 className="font-semibold">{project.name}</h3>
<p className="mt-1 text-sm text-neutral-400">{project.description}</p>
</motion.div>
);
}
Swipe-to-dismiss notification:
function Notification({ message, onDismiss }: {
message: string;
onDismiss: () => void;
}) {
return (
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > 100) {
onDismiss();
}
}}
exit={{ opacity: 0, x: info => info.offset.x > 0 ? 200 : -200 }}
className="p-4 bg-neutral-800 rounded-lg cursor-grab active:cursor-grabbing"
>
{message}
</motion.div>
);
}
Drag interactions need careful tuning. Set dragConstraints to prevent elements from being dragged into nonsensical positions. Set a threshold for the action (like 100px in the swipe-to-dismiss example) so accidental small drags do not trigger the behavior.
Performance Considerations
Animation performance is non-negotiable. A janky animation is worse than no animation at all.
Stick to transform and opacity. These are the only CSS properties that can be animated on the GPU compositor thread without triggering layout or paint. Framer Motion’s x, y, scale, rotate, and opacity properties all use transforms under the hood. Animating width, height, top, left, or margin causes layout recalculations and will drop frames.
Use layout animations judiciously. The layout prop is powerful but expensive. It measures element dimensions before and after a change, then animates the difference using transforms. For a list of 10 items, this is fine. For a list of 500 items, it will lag. Paginate or virtualize large lists.
Reduce bundle size with LazyMotion:
import { LazyMotion, domAnimation, m } from "motion/react";
function App() {
return (
<LazyMotion features={domAnimation}>
<m.div animate={{ opacity: 1 }}>
{/* Use m.div instead of motion.div */}
{/* Only loads the animation features you actually use */}
</m.div>
</LazyMotion>
);
}
LazyMotion with domAnimation gives you the core animation features at roughly 4.6 KB instead of the full 34 KB bundle. If you are not using drag, layout animations, or SVG path animations, this is a free performance win.
Respect reduced motion preferences:
import { useReducedMotion } from "motion/react";
function AnimatedComponent() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
>
Content
</motion.div>
);
}
Some users have motion sensitivity, vestibular disorders, or simply prefer minimal animation. The prefers-reduced-motion media query exists for a reason. Respect it. This is not optional — it is an accessibility requirement.

The Taste Test
Here is our internal checklist before shipping any animation:
- Can you explain why this animation exists in one sentence? If you cannot, remove it.
- Is the duration under 300ms for interactions and under 500ms for transitions? Longer animations feel sluggish in a productivity app. Marketing sites get slightly more leeway.
- Does it work on a mid-range phone? Test on a throttled connection with CPU slowdown enabled. Chrome DevTools makes this easy.
- Does it respect reduced motion preferences? Check by enabling “Reduce motion” in your OS accessibility settings.
- Would a first-time user understand the interface without the animation? Animation should enhance comprehension, not be required for it.
The best motion design disappears. Users do not say “great animations.” They say “this app feels really polished” or “everything just works.” That is the goal.
Less Is More
We will end with the advice we give every client: start with zero animations, then add only what is necessary. It is much easier to add animation to a static interface than to remove gratuitous animation from an over-animated one.
The three highest-ROI animations for any web app are:
- Page transitions — a simple opacity fade between routes
- List enter/exit — items sliding in when added and collapsing out when removed
- Modal enter/exit — backdrop fade with a subtle scale on the dialog
Start with those three. Ship it. Live with it for a week. Then add more only if the interface tells you it needs it.
If you are building a web app and want help getting the motion design right — or anything else in the frontend stack — reach out at hello@threshline.com. We care about the details that make products feel good to use.