Hero animations in Flutter: solved exercise

Hero animations in Flutter: solved exercise

The Hero widget creates a continuous visual transition of an element between two routes. Flutter automatically animates the widget’s position, size, and shape from the source to the destination — without a single line of animation code. It is the most impactful technique for connecting screens with a shared visual element.

Problem statement

Implement an image gallery where:

  • Thumbnails in the grid each have a Hero with a unique tag.
  • Tapping a thumbnail animates the image into the detail screen.
  • Going back animates the image back to its original position.
  • The detail screen includes an animated title using a text Hero.

Dependencies

Flutter SDK only.

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
// lib/main.dart
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: GalleryPage()));

// ── Model ──────────────────────────────────────────────────────────────────────
class Photo {
  final String id;
  final String title;
  final Color color;
  final IconData icon;

  const Photo({
    required this.id,
    required this.title,
    required this.color,
    required this.icon,
  });
}

const _photos = [
  Photo(id: 'mountains', title: 'Mountains', color: Color(0xFF1a3a5c), icon: Icons.terrain),
  Photo(id: 'ocean', title: 'Ocean', color: Color(0xFF006994), icon: Icons.waves),
  Photo(id: 'forest', title: 'Forest', color: Color(0xFF2d6a4f), icon: Icons.park),
  Photo(id: 'desert', title: 'Desert', color: Color(0xFFc9963a), icon: Icons.wb_sunny),
  Photo(id: 'city', title: 'City', color: Color(0xFF4a4e69), icon: Icons.location_city),
  Photo(id: 'aurora', title: 'Aurora', color: Color(0xFF6a0572), icon: Icons.auto_awesome),
];

// ── Gallery screen ─────────────────────────────────────────────────────────────
class GalleryPage extends StatelessWidget {
  const GalleryPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Hero Gallery')),
      body: GridView.builder(
        padding: const EdgeInsets.all(12),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 12,
          mainAxisSpacing: 12,
        ),
        itemCount: _photos.length,
        itemBuilder: (context, index) {
          final photo = _photos[index];
          return GestureDetector(
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => PhotoDetailPage(photo: photo),
              ),
            ),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Hero(
                tag: 'photo-${photo.id}',
                child: _PhotoCard(photo: photo),
              ),
            ),
          );
        },
      ),
    );
  }
}

// ── Detail screen ──────────────────────────────────────────────────────────────
class PhotoDetailPage extends StatelessWidget {
  final Photo photo;
  const PhotoDetailPage({super.key, required this.photo});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        foregroundColor: Colors.white,
        elevation: 0,
      ),
      body: Column(
        children: [
          // Hero image — takes 60% of the screen
          Expanded(
            flex: 3,
            child: Hero(
              tag: 'photo-${photo.id}',
              child: _PhotoCard(photo: photo, large: true),
            ),
          ),
          // Information below the image
          Expanded(
            flex: 2,
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Title with Hero to animate it too
                  Hero(
                    tag: 'title-${photo.id}',
                    child: Material(
                      color: Colors.transparent,
                      child: Text(
                        photo.title,
                        style: const TextStyle(
                          color: Colors.white,
                          fontSize: 32,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 12),
                  const Text(
                    'A detailed description of this amazing place would appear here. '
                    'You can expand this section with real content from your API.',
                    style: TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
                  ),
                  const Spacer(),
                  FilledButton.icon(
                    onPressed: () {},
                    icon: const Icon(Icons.favorite_border),
                    label: const Text('Save to favorites'),
                    style: FilledButton.styleFrom(
                      backgroundColor: photo.color,
                      minimumSize: const Size(double.infinity, 48),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ── Shared widget (thumbnail and detail use the same one) ─────────────────────
class _PhotoCard extends StatelessWidget {
  final Photo photo;
  final bool large;

  const _PhotoCard({required this.photo, this.large = false});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: photo.color,
      child: Stack(
        fit: StackFit.expand,
        children: [
          // Background icon
          Center(
            child: Icon(
              photo.icon,
              size: large ? 120 : 60,
              color: Colors.white.withOpacity(0.3),
            ),
          ),
          // Gradient + title on the thumbnail
          if (!large)
            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: Container(
                padding: const EdgeInsets.all(8),
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.bottomCenter,
                    end: Alignment.topCenter,
                    colors: [Colors.black54, Colors.transparent],
                  ),
                ),
                child: Hero(
                  tag: 'title-${photo.id}',
                  child: Material(
                    color: Colors.transparent,
                    child: Text(
                      photo.title,
                      style: const TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 14,
                      ),
                    ),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

How it works internally

  1. Flutter identifies pairs of Hero widgets with the same tag in the source and destination routes.
  2. It creates a temporary overlay layer and “flies” the widget from the source Rect to the destination Rect during the transition.
  3. When complete, the widget occupies its final position in the destination widget tree.

Customizing the flight with flightShuttleBuilder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Hero(
  tag: 'photo-${photo.id}',
  flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
    return AnimatedBuilder(
      animation: animation,
      builder: (_, child) => Opacity(
        opacity: animation.value,
        child: _PhotoCard(photo: photo, large: true),
      ),
    );
  },
  child: _PhotoCard(photo: photo),
),

Use it to display a different version of the widget during the flight (for example, the high-resolution version).

Common mistakes

  • Duplicate tags on the same route: two Hero widgets with the same tag on the same screen throw FlutterError: There are multiple heroes that share the same tag. Tags must be unique per route.
  • Text with Hero without Material: the text inherits the style of the source screen during the flight, producing unexpected white/black text. Wrap Text in Material(color: Colors.transparent).
  • Hero inside Opacity with value 0: the source widget must be visible for the flight to work. If you hide it with Opacity(opacity: 0), use Visibility or remove it entirely.

Hero with go_router

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// go_router with Hero works the same way — the Hero animation
// detects matching widgets between routes automatically.
// Just ensure both pages are under the same Navigator.
GoRoute(
  path: '/photo/:id',
  pageBuilder: (context, state) => CustomTransitionPage(
    child: PhotoDetailPage(photo: getPhoto(state.pathParameters['id']!)),
    transitionsBuilder: (_, animation, __, child) =>
        FadeTransition(opacity: animation, child: child),
  ),
),

Practical use

Hero animations are used in image galleries, product cards that expand on tap, user avatars that grow when viewing a profile, and any shared element that visually connects two screens.

Guided practice and next step

FAQ

Does Hero work with go_router?

Yes, as long as the routes share the same Navigator. In go_router with ShellRoute or nested routes, make sure both pages are under the same Navigator level.

Can I animate multiple Hero elements at the same time in the same transition?

Yes. You can have as many Hero pairs as you want in the same route transition. Each one flies independently.

Does Hero work with showDialog or showModalBottomSheet?

Not directly — these methods create a secondary Navigator. For Hero-style animations in dialogs, use a custom overlay or a full-page transition with PageRouteBuilder.