IGrow Group of Companies

IGrow Design System — Motion Guidelines

← Design System

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 transform and opacity only — these are GPU-composited and safe on low-end devices. Never animate width, height, top, or left in 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-delay manually

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 transform animations (translate, scale, rotate)
  • Preserves opacity and color changes (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 animations on Android) — all animations must stop
  • Verify staggered lists cap at 4–8 items; anything longer should not animate in production
  • Check that AnimatePresence exit animations complete before the element is removed from the DOM (no flash)
  • Confirm layout prop is not used in any mobile-critical component