← Design System Index | Token Reference | Accessibility
IGrow Design System — Motion Guidelines
Motion is a communication tool, not decoration. Every animation should answer one of three questions: What changed? Where did it go? What can I do? If an animation doesn't answer one of these, remove it.
Performance context. 80% of IGrow traffic is Android mobile. Animate
transformandopacityonly — these are GPU-composited and safe on low-end devices. Never animatewidth,height,top, orleftin product UI.
1. Motion principles
| Principle | Rule |
|---|---|
| Purposeful | Every animation communicates state change, hierarchy, or affordance |
| Subtle | Translate distances 4–8 px max; no full-screen sweeps in product UI |
| Performant | transform + opacity only; GPU-composited, no layout thrash |
| Accessible | All motion is automatically suppressed when prefers-reduced-motion: reduce is active |
| Directional | Elements entering use --easing-enter (decelerate); elements leaving use --easing-exit (accelerate) |
2. Token reference
Easing
| Token | Value | Use |
|---|---|---|
--easing-standard |
cubic-bezier(0.2, 0.8, 0.2, 1) |
Hover states, colour shifts, general component transitions |
--easing-enter |
cubic-bezier(0.0, 0.0, 0.2, 1) |
Elements arriving on screen — decelerates to rest |
--easing-exit |
cubic-bezier(0.4, 0.0, 1, 1) |
Elements leaving screen — accelerates out |
The enter/exit distinction matters: an element arriving should decelerate (it's landing), and an element departing should accelerate (it's leaving with intention). Using the same curve for both produces motion that feels mechanical.
Duration
| Token | Value | Tailwind | Use |
|---|---|---|---|
--duration-instant |
50ms |
duration-instant |
Sub-perceptual feedback — checkbox tick, toggle flip, radio select |
--duration-fast |
150ms |
duration-fast |
Hover colour/border shift, focus ring |
--duration-base |
200ms |
duration-base |
Card lift, button press, field focus ring, entrance animations |
--duration-slow |
250ms |
duration-slow |
Modal enter, drawer slide, accordion open |
Motion distances
| Token | Value | Use |
|---|---|---|
--motion-distance-sm |
4px |
Micro-lift on hover, subtle parallax |
--motion-distance-md |
8px |
Card/modal entrance slide — the default for .ig-animate-* classes |
--motion-distance-lg |
24px |
Drawer or full-panel entrance (pair with --duration-slow) |
3. Entrance animation classes
Four ready-to-use entrance classes are available in system/components.css. All animate opacity + transform using --easing-enter and --duration-base.
| Class | Motion | Best for |
|---|---|---|
.ig-animate-fade-in |
Opacity 0 → 1 | Overlays, tooltips, badges |
.ig-animate-fade-up |
Opacity + translateY(8px → 0) | Cards, content sections, page sections |
.ig-animate-scale-in |
Opacity + scale(0.97 → 1) | Modals, popovers, dropdowns |
.ig-animate-slide-right |
Opacity + translateX(-8px → 0) | Secondary panels, sidebar reveals |
Basic usage
<!-- Fade up on page load -->
<section class="ig-animate-fade-up">
<h2>Featured properties</h2>
…
</section>
<!-- Scale in for a modal body -->
<div class="ig-modal__body ig-animate-scale-in">…</div>
Stagger with --ig-delay
Pass --ig-delay as an inline CSS custom property to stagger items in a list. Increment by 60ms per item — enough to read the sequence without feeling slow.
<ul class="ig-property-grid">
<li class="ig-animate-fade-up" style="--ig-delay: 0ms">…</li>
<li class="ig-animate-fade-up" style="--ig-delay: 60ms">…</li>
<li class="ig-animate-fade-up" style="--ig-delay: 120ms">…</li>
<li class="ig-animate-fade-up" style="--ig-delay: 180ms">…</li>
</ul>
Cap stagger at 4 items (180ms total delay). Beyond that the last item feels abandoned.
Tailwind (className approach)
The animation classes are in components.css and work alongside Tailwind — just add them directly:
<article className="ig-animate-fade-up ig-card ig-card--interactive">…</article>
For stagger in React, compute the delay inline:
{properties.map((p, i) => (
<article
key={p.id}
className="ig-animate-fade-up"
style={{ '--ig-delay': `${i * 60}ms` }}
>
…
</article>
))}
4. Framer Motion integration (React surfaces)
Framer Motion (now also distributed as motion) is the right tool for React surfaces where you need:
- Exit animations — CSS cannot animate elements as they unmount
AnimatePresence— coordinated enter/exit sequences (modals, toasts, route changes)- Gesture-driven motion — drag, pan, swipe
- Programmatic stagger — without managing
animation-delaymanually
For simple hover states and one-way entrance animations, use the CSS classes above instead. They cost zero bundle size.
Setup — wrap the app root once
Use LazyMotion with domAnimation to keep the initial bundle to ~20 KB (gzipped). Avoid domMax on mobile — it adds drag and layout animation support you don't need.
// app/layout.tsx (or _app.tsx)
import { LazyMotion, domAnimation, MotionConfig } from 'framer-motion'
export default function RootLayout({ children }) {
return (
<MotionConfig reducedMotion="user">
<LazyMotion features={domAnimation}>
{children}
</LazyMotion>
</MotionConfig>
)
}
reducedMotion="user" reads the OS prefers-reduced-motion setting and automatically disables transform animations while preserving opacity/colour transitions (the accessible subset).
Shared variant presets — lib/motion-variants.js
Create this file once and import across the app. Values match the design system tokens exactly.
// lib/motion-variants.js
// Fade up — cards, content sections, list items
export const fadeUp = {
initial: { opacity: 0, y: 8 }, // --motion-distance-md
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -4 },
transition: {
duration: 0.2, // --duration-base
ease: [0, 0, 0.2, 1], // --easing-enter
},
}
// Scale in — modals, popovers, dropdowns
export const scaleIn = {
initial: { opacity: 0, scale: 0.97 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.97 },
transition: {
duration: 0.2,
ease: [0, 0, 0.2, 1],
},
}
// Slide right — secondary panels, drawers
export const slideRight = {
initial: { opacity: 0, x: -8 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -8 },
transition: {
duration: 0.25, // --duration-slow
ease: [0, 0, 0.2, 1],
},
}
// Stagger container — wrap a list of items
export const staggerChildren = {
animate: {
transition: { staggerChildren: 0.06 }, // 60ms between children
},
}
Usage patterns
Staggered property card grid:
import { m } from 'framer-motion'
import { fadeUp, staggerChildren } from '@/lib/motion-variants'
export function PropertyGrid({ properties }) {
return (
<m.ul
className="ig-property-grid"
variants={staggerChildren}
initial="initial"
animate="animate"
>
{properties.map(p => (
<m.li key={p.id} variants={fadeUp}>
<PropertyCard {...p} />
</m.li>
))}
</m.ul>
)
}
Modal with exit animation:
import { m, AnimatePresence } from 'framer-motion'
import { scaleIn } from '@/lib/motion-variants'
export function Modal({ isOpen, children }) {
return (
<AnimatePresence>
{isOpen && (
<>
<m.div
className="ig-modal__backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
<m.div
className="ig-modal"
variants={scaleIn}
initial="initial"
animate="animate"
exit="exit"
>
{children}
</m.div>
</>
)}
</AnimatePresence>
)
}
Toast / notification:
<AnimatePresence mode="popLayout">
{toasts.map(toast => (
<m.div
key={toast.id}
className="ig-alert"
variants={fadeUp}
initial="initial"
animate="animate"
exit="exit"
layout
>
{toast.message}
</m.div>
))}
</AnimatePresence>
What to avoid on Android mobile
| Avoid | Reason | Alternative |
|---|---|---|
layout prop |
Measures and recalculates DOM geometry every frame | Use transform positioning instead |
layoutId shared transitions |
Same measurement overhead, across components | CSS transition + opacity |
Animating width / height |
Not GPU-composited; causes layout recalc | scaleX / scaleY + transform-origin |
| Large lists animating simultaneously | Drops frames on low-end CPUs | Stagger with 60ms delay; cap at ~8 items |
domMax feature bundle |
Adds +30 KB for drag/layout — not needed for most surfaces | Stick to domAnimation |
5. prefers-reduced-motion strategy
CSS (automatic)
system/tokens.css contains a universal override:
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 0.01ms;
--duration-base: 0.01ms;
--duration-slow: 0.01ms;
--duration-instant: 0.01ms;
}
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Every component using --duration-* tokens and every .ig-animate-* class is automatically covered. No per-component work required.
Framer Motion
<MotionConfig reducedMotion="user"> reads the OS preference and:
- Disables
transformanimations (translate, scale, rotate) - Preserves
opacityandcolorchanges (considered non-vestibular) - Applies to all
m.*components in the subtree
No further work is needed if the app root is wrapped correctly (see Section 4 setup above).
6. Testing checklist
- Test at 384px viewport on a real mid-range Android device — not just Chrome DevTools mobile emulation
- Open Chrome DevTools → Performance tab → record a page load → look for Long Animation Frame warnings (>50ms = jank)
- Enable OS reduced-motion setting (
Settings → Accessibility → Remove animationson Android) — all animations must stop - Verify staggered lists cap at 4–8 items; anything longer should not animate in production
- Check that
AnimatePresenceexit animations complete before the element is removed from the DOM (no flash) - Confirm
layoutprop is not used in any mobile-critical component