Ahmet Balaman LogoAhmet Balaman

Flutter: Elevating User Experience with Hero Animations and Page Transition Effects

personAhmet Balaman
calendar_today
FlutterAnimationHeroPage TransitionUI/UX

Have you ever noticed how in an e-commerce app, when you touch a product image, that small picture smoothly grows and appears on the detail page? Or how a photo on Instagram opens in full screen so fluidly when you tap it? That magic is possible with Hero animations.

Animations in Flutter are one of the most powerful tools that elevate user experience from ordinary to extraordinary. In this article, we'll explore techniques that will bring cinema-quality transitions to your applications.

What is Hero Animation?

Hero animation enables a widget shared between two pages to transition smoothly. The widget "flies" seamlessly from its position on the first page to its position on the second page.

It takes its name from mythological heroes - just like heroes, the widget journeys between two worlds (pages).

Why Use Hero?

Users experience context loss during page transitions. Hero animations solve this problem:

  • Visual Continuity: Users always know where they are
  • Professional Look: Your app looks like an industry leader
  • Low Effort, High Impact: A few lines of code, tremendous results

Anatomy of Hero

You need two things for a Hero animation:

  1. Same tag: Hero widgets on both pages must have the same tag value
  2. Hero widget: Wrap your widget with Hero on both pages

Here's the simplest example:

// First page
Hero(
  tag: 'profile-photo',
  child: CircleAvatar(
    backgroundImage: NetworkImage('https://picsum.photos/200'),
    radius: 30,
  ),
)

// Second page
Hero(
  tag: 'profile-photo', // Same tag!
  child: CircleAvatar(
    backgroundImage: NetworkImage('https://picsum.photos/200'),
    radius: 100, // Different size - Flutter automatically animates
  ),
)

The tag is like the DNA of the animation. Flutter finds widgets with the same tag and creates an animation between them.

Live Demo

You can try these widgets in the interactive example below:

💡 If the example above doesn't load, click DartPad to run it in a new tab.

Real World Example: Product Card to Detail Page

The most common use case in e-commerce apps:

class ProductCard extends StatelessWidget {
  final String productId;
  final String imageUrl;
  final String name;
  
  const ProductCard({
    required this.productId,
    required this.imageUrl,
    required this.name,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => ProductDetailPage(
              productId: productId,
              imageUrl: imageUrl,
              name: name,
            ),
          ),
        );
      },
      child: Card(
        child: Column(
          children: [
            Hero(
              tag: 'product-$productId', // Unique tag
              child: Image.network(
                imageUrl,
                height: 150,
                width: double.infinity,
                fit: BoxFit.cover,
              ),
            ),
            Padding(
              padding: EdgeInsets.all(8),
              child: Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
            ),
          ],
        ),
      ),
    );
  }
}

class ProductDetailPage extends StatelessWidget {
  final String productId;
  final String imageUrl;
  final String name;
  
  const ProductDetailPage({
    required this.productId,
    required this.imageUrl,
    required this.name,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(name)),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Hero(
            tag: 'product-$productId', // Same tag!
            child: Image.network(
              imageUrl,
              height: 300,
              width: double.infinity,
              fit: BoxFit.cover,
            ),
          ),
          Padding(
            padding: EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  name,
                  style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                ),
                SizedBox(height: 16),
                Text(
                  'Product description goes here...',
                  style: TextStyle(fontSize: 16),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Customizing Hero Animation

1. Changing Animation Duration

Default duration is 300ms. Use transitionDuration to change it:

Navigator.push(
  context,
  PageRouteBuilder(
    transitionDuration: Duration(milliseconds: 600), // Slower
    pageBuilder: (context, animation, secondaryAnimation) => DetailPage(),
  ),
);

2. Custom FlightShuttleBuilder

Control how the widget appears during "flight":

Hero(
  tag: 'custom-hero',
  flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
    // Show different widget during animation
    return Material(
      color: Colors.transparent,
      child: ScaleTransition(
        scale: animation.drive(Tween(begin: 0.0, end: 1.0)),
        child: Icon(Icons.favorite, size: 100, color: Colors.red),
      ),
    );
  },
  child: Icon(Icons.favorite_border),
)

3. Customize Path with CreateRectTween

Determine which path the widget follows:

Hero(
  tag: 'curved-hero',
  createRectTween: (begin, end) {
    return MaterialRectArcTween(begin: begin, end: end); // Curved path
  },
  child: Container(width: 100, height: 100, color: Colors.blue),
)

Page Transition Animations

Hero alone is great, but what if you also customize the page transition? This is where PageRouteBuilder comes in.

SlideTransition - Sliding Transition

Pages can slide from right to left, left to right, top to bottom:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      const begin = Offset(1.0, 0.0); // Start from right
      const end = Offset.zero; // End at center
      const curve = Curves.easeInOut;
      
      var tween = Tween(begin: begin, end: end).chain(
        CurveTween(curve: curve),
      );
      
      var offsetAnimation = animation.drive(tween);
      
      return SlideTransition(
        position: offsetAnimation,
        child: child,
      );
    },
  ),
);

Direction Examples:

// Right to left
const begin = Offset(1.0, 0.0);

// Left to right
const begin = Offset(-1.0, 0.0);

// Bottom to top
const begin = Offset(0.0, 1.0);

// Top to bottom
const begin = Offset(0.0, -1.0);

// Diagonal (from bottom right)
const begin = Offset(1.0, 1.0);

FadeTransition - Fade Transition

iOS-style smooth transition:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(
        opacity: animation,
        child: child,
      );
    },
  ),
);

ScaleTransition - Scale Transition

Page opens from small to large:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation, child) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      var scaleTween = Tween<double>(begin: 0.0, end: 1.0).chain(
        CurveTween(curve: Curves.elasticOut),
      );
      
      return ScaleTransition(
        scale: animation.drive(scaleTween),
        child: child,
      );
    },
  ),
);

RotationTransition - Rotation Transition

Page arrives while rotating:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return RotationTransition(
        turns: animation,
        child: child,
      );
    },
  ),
);

Combining Multiple Animations

The real magic happens when you combine animations:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      // Both fade and slide
      return FadeTransition(
        opacity: animation,
        child: SlideTransition(
          position: Tween<Offset>(
            begin: Offset(0.0, 0.3),
            end: Offset.zero,
          ).animate(animation),
          child: child,
        ),
      );
    },
  ),
);

Custom Transition: "Zoom Fade Slide"

The "zoom + fade + slide" combination seen in professional apps:

class ZoomFadeSlideTransition extends StatelessWidget {
  final Animation<double> animation;
  final Widget child;
  
  const ZoomFadeSlideTransition({
    required this.animation,
    required this.child,
  });

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: Tween<Offset>(
          begin: Offset(0, 0.1),
          end: Offset.zero,
        ).animate(CurvedAnimation(
          parent: animation,
          curve: Curves.easeOut,
        )),
        child: ScaleTransition(
          scale: Tween<double>(
            begin: 0.9,
            end: 1.0,
          ).animate(CurvedAnimation(
            parent: animation,
            curve: Curves.easeOut,
          )),
          child: child,
        ),
      ),
    );
  }
}

// Usage:
Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return ZoomFadeSlideTransition(
        animation: animation,
        child: child,
      );
    },
  ),
);

Animation Character with Curves

Curve determines the acceleration/deceleration behavior of the animation:

// Linear - Gives mechanical feel
Curves.linear

// Slow start, speed up, slow end - Most natural
Curves.easeInOut

// Smooth start
Curves.easeIn

// Smooth end
Curves.easeOut

// Elastic - Playful feel
Curves.elasticOut

// Bounce back - Eye-catching
Curves.bounceOut

// Decelerate
Curves.decelerate

// Overshoot
Curves.anticipate

Pro Tip: Use Curves.easeInOut or Curves.easeOut for user interfaces. Extreme curves like Curves.bounceOut are eye-catching but can be tiring.

Performance Tips

1. AnimatedOpacity Instead of Opacity

// ❌ Bad - Rebuilds every frame
Opacity(
  opacity: _controller.value,
  child: HeavyWidget(),
)

// ✅ Good - Optimized rebuild
AnimatedOpacity(
  opacity: isVisible ? 1.0 : 0.0,
  duration: Duration(milliseconds: 300),
  child: HeavyWidget(),
)

2. Cache Heavy Widgets

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(
        opacity: animation,
        child: child, // child is already cached
      );
    },
  ),
);

3. Use RepaintBoundary

Only necessary parts should be redrawn during animation:

RepaintBoundary(
  child: Hero(
    tag: 'image',
    child: Image.network('...'),
  ),
)

Real World Use Cases

1. Photo Gallery

GridView.builder(
  itemCount: photos.length,
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    crossAxisSpacing: 4,
    mainAxisSpacing: 4,
  ),
  itemBuilder: (context, index) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => PhotoViewPage(
              photoUrl: photos[index],
              tag: 'photo-$index',
            ),
          ),
        );
      },
      child: Hero(
        tag: 'photo-$index',
        child: Image.network(
          photos[index],
          fit: BoxFit.cover,
        ),
      ),
    );
  },
)

2. Profile Photo Expansion

// List item
ListTile(
  leading: GestureDetector(
    onTap: () => Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => ProfilePage(user: user),
      ),
    ),
    child: Hero(
      tag: 'avatar-${user.id}',
      child: CircleAvatar(
        backgroundImage: NetworkImage(user.photoUrl),
      ),
    ),
  ),
  title: Text(user.name),
)

// Profile page
Scaffold(
  body: Column(
    children: [
      Hero(
        tag: 'avatar-${user.id}',
        child: CircleAvatar(
          backgroundImage: NetworkImage(user.photoUrl),
          radius: 80,
        ),
      ),
      // Other info...
    ],
  ),
)

3. FAB to Detail Page

FloatingActionButton(
  heroTag: 'fab', // Special tag for FAB
  onPressed: () {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => CreatePostPage()),
    );
  },
  child: Hero(
    tag: 'create-icon',
    child: Icon(Icons.add),
  ),
)

Common Mistakes and Solutions

Error 1: Duplicate Hero Tag

Problem: Using the same tag for multiple Heroes.

// ❌ Wrong
ListView.builder(
  itemBuilder: (context, index) {
    return Hero(
      tag: 'item', // All have same tag!
      child: ListTile(title: Text('Item $index')),
    );
  },
)

Solution: Use unique tags.

// ✅ Correct
ListView.builder(
  itemBuilder: (context, index) {
    return Hero(
      tag: 'item-$index', // Each item is unique
      child: ListTile(title: Text('Item $index')),
    );
  },
)

Error 2: Hero and Material Widget Incompatibility

Problem: Strange animations when using Material widget inside Hero.

Solution: Use Material on both pages or don't use it at all.

// First page
Hero(
  tag: 'card',
  child: Material(
    child: Card(child: ...),
  ),
)

// Second page
Hero(
  tag: 'card',
  child: Material( // Same Material wrapping
    child: Card(child: ...),
  ),
)

Error 3: Animation Too Fast or Slow

Solution: Optimal duration is between 200-400ms.

// Too fast: 100ms
// Optimal: 300ms
// Too slow: 800ms+

PageRouteBuilder(
  transitionDuration: Duration(milliseconds: 300), // Ideal
  ...
)

Bonus: Reusable Transition Widget

A helper class you can use throughout your project:

class PageTransitionHelper {
  static Route createRoute(Widget page, {PageTransitionType type = PageTransitionType.fade}) {
    return PageRouteBuilder(
      pageBuilder: (context, animation, secondaryAnimation) => page,
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        switch (type) {
          case PageTransitionType.fade:
            return FadeTransition(opacity: animation, child: child);
          
          case PageTransitionType.slide:
            return SlideTransition(
              position: Tween<Offset>(
                begin: Offset(1.0, 0.0),
                end: Offset.zero,
              ).animate(animation),
              child: child,
            );
          
          case PageTransitionType.scale:
            return ScaleTransition(
              scale: Tween<double>(begin: 0.0, end: 1.0).animate(
                CurvedAnimation(parent: animation, curve: Curves.easeOut),
              ),
              child: child,
            );
          
          case PageTransitionType.rotation:
            return RotationTransition(turns: animation, child: child);
          
          default:
            return child;
        }
      },
    );
  }
}

enum PageTransitionType { fade, slide, scale, rotation }

// Usage:
Navigator.push(
  context,
  PageTransitionHelper.createRoute(
    NextPage(),
    type: PageTransitionType.slide,
  ),
);

Summary

  • Hero Animation: Widgets "fly" between pages (requires same tag)
  • SlideTransition: Sliding effect (direction control with Offset)
  • FadeTransition: Fade effect (opacity animation)
  • ScaleTransition: Scale effect (grow/shrink)
  • RotationTransition: Rotation effect
  • PageRouteBuilder: Foundation for creating custom transitions
  • Curves: Determines animation character (easeInOut, elasticOut, etc.)
  • Combination: Multiple transitions can be combined
  • Performance: Use AnimatedOpacity, RepaintBoundary, cache
  • Unique Tags: Use different tags for each Hero

Animations are the details that enchant user experience. When used appropriately without exaggeration, they make your app look professional. Now it's your turn - bring your apps to life!

Comments