Component Patterns
15 minComponent Patterns
Every component type has a natural motion vocabulary. A button press should feel tactile. A modal should feel substantial. A tooltip should feel light. These aren't subjective preferences—they're cognitive expectations built from years of operating physical and digital interfaces. When the animation matches the component's role, the interface feels coherent. When it doesn't, something feels off even if the user can't say what.
This lesson covers the specific animation recipe for each common component type—duration, easing, properties, and the mistakes that make each one feel wrong.
scale(0.97) on press, spring back on release
Buttons
Button animation serves one purpose: feedback. The user needs to know the press was registered. Nothing more.
The correct implementation is a scale transform on :active—the button shrinks slightly (to scale(0.97) or scale(0.98)) while pressed and returns to scale(1) on release. The transition should be fast—50–80ms—and use ease-out. The scale reduction is barely visible but subconsciously tactile.
.button {
transition: transform 80ms ease-out;
}
.button:active {
transform: scale(0.97);
}
Don't add a spring or bounce to button presses in product UI. It feels playful on a marketing page and sluggish on a dashboard where the user clicks thirty buttons an hour. Don't animate background-color changes on press—they're binary state changes that should be instant. Don't add a transition to disabled states—a button that slowly fades to 50% opacity feels like it's "dying" rather than simply being unavailable.
Popovers and dropdowns
Popovers emerge from a trigger. Their animation should reinforce this spatial relationship.
Enter: Scale from 0.9 to 1 with opacity from 0 to 1. Duration: 150–200ms. Easing: cubic-bezier(0.22, 1, 0.36, 1) (fast start, gentle settle). Transform-origin: set to the edge nearest the trigger element.
Exit: Opacity from 1 to 0, scale from 1 to 0.95. Duration: 100–150ms. Easing: cubic-bezier(0.4, 0, 1, 1) (accelerating away). Faster than the entrance—the user has dismissed it and wants it gone.
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
style={{ transformOrigin: "top center" }}
/>
Don't use translateY for popovers that appear below their trigger—a downward slide implies the popover is "falling," which contradicts the spatial model. Scale from the trigger point instead. Do use translateY for dropdown menus that visually attach to a top nav bar—here, the slide reinforces the "dropping down" metaphor.
Tooltips
Tooltips are the lightest overlays. They carry a single piece of supplementary information and should appear and disappear with minimal fanfare.
Enter: Opacity from 0 to 1, scale from 0.85 to 1. Duration: 100–150ms. A slight translateY (4px toward the trigger) can add spatial context. Transform-origin: the edge facing the trigger.
Exit: Opacity to 0. Duration: 75–100ms. No scale on exit—just a fast fade. Tooltips are ephemeral and should leave without ceremony.
The critical detail: tooltip enter delay. Don't show the tooltip instantly on hover. A 300–500ms delay prevents tooltips from flickering as the user moves their cursor across a toolbar. But once one tooltip is visible, subsequent tooltips on nearby elements should appear immediately—the user is now in "tooltip browsing mode."
Drawers and sheets
Drawers slide from an edge. Their animation is primarily translateX (side drawers) or translateY (bottom sheets).
Enter: Translate from the edge into view. Duration: 250–350ms. Easing: cubic-bezier(0.32, 0.72, 0, 1)—a custom curve that starts with moderate speed and settles gently. A spring works well here too: { stiffness: 400, damping: 40 }.
Exit: Translate back to the edge. Duration: 200–250ms. Easing: cubic-bezier(0.4, 0, 1, 1) (ease-in, accelerating out). The drawer should feel like it's being pushed away.
Pair the drawer with an overlay backdrop that fades in simultaneously (opacity 0 to 0.5 on a black background). The backdrop's transition should match the drawer's duration but can use a simpler ease-out curve.
Don't scale drawers. They're anchored to a physical edge and should move along a single axis. Scaling breaks the spatial metaphor—the drawer should slide, not shrink.
Modals and dialogs
Modals are the heaviest overlay. They interrupt the user's workflow and demand attention, so their animation should feel substantial—but not slow.
Enter: Scale from 0.95 to 1, opacity from 0 to 1. Duration: 200–300ms. Easing: cubic-bezier(0.22, 1, 0.36, 1). Add a subtle translateY (8–12px upward) for a "rising into place" feel. Transform-origin: centre, or the trigger element's position if available.
Exit: Opacity from 1 to 0, scale from 1 to 0.95. Duration: 150–200ms. A slight translateY downward (4–8px) on exit adds to the "settling away" feel. Faster than entrance.
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -4 }}
transition={{
duration: 0.25,
ease: [0.22, 1, 0.36, 1],
}}
/>
Toasts and notifications
Toasts slide in from an edge—typically the top-right or bottom-centre. They're transient and should feel light.
Enter: Translate from off-screen into view. Duration: 200–250ms. Easing: ease-out. A subtle spring works well for a small bounce at the end: { stiffness: 400, damping: 30 }.
Exit: Translate back off-screen, or fade with a slight scale-down to 0.95. Duration: 150–200ms. Toasts that auto-dismiss should begin their exit transition with a brief opacity fade (200ms) before sliding out.
Don't animate toasts from the centre of the screen. They're peripheral UI—they should enter from the periphery. Do stagger multiple toasts if they stack—each new toast pushes existing toasts down (or up) with a layout animation.
Hover effects with CSS
Hover states are high-frequency—the user hovers dozens or hundreds of times per session. Keep transitions fast and property-limited.
.card {
transition: transform 150ms ease-out, box-shadow 150ms ease-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.08);
}
Use transform and box-shadow only. Don't transition border-color or background-color on hover—the change should be instant. Don't scale cards on hover above 1.02—anything more feels like the card is jumping at you. A subtle translateY(-2px) with an enhanced shadow creates the illusion of elevation without feeling aggressive.
For interactive list items, a background-color highlight on hover is better than a transform—the user is scanning, not selecting. Use transition: background-color 100ms ease with a very fast duration so the highlight tracks the cursor without lag.