Skip to content
ExponaBLOG
FLUTTER · DAY 051 / 100

AnimatedContainer: what every Flutter developer should know

The easiest animation in Flutter.

Expona Admin8 min read

AnimatedContainer: The Easiest Animation in Flutter

If you've ever wanted to animate a widget's properties without diving into controller boilerplate, AnimatedContainer is your answer. It's one of those widgets that feels almost magical the first time you use it—you change a property, and Flutter smoothly transitions between the old and new values over a duration you specify. Today on Day 51, we're going to explore how this simple-but-powerful widget can elevate your app's feel with minimal code.

Why AnimatedContainer Matters

In my experience building production apps, I've found that most of the polish users notice comes from micro-interactions. A button that grows when tapped. A card that expands to reveal details. A color that shifts when state changes. These aren't flashy animations, but they create a sense of responsiveness that feels intentional.

The challenge is that manually managing animation controllers, tickers, and lerps gets tedious fast. That's where AnimatedContainer shines. It handles all the interpolation for you. You just declare the target state, and it animates there. No controllers, no listeners, no AnimationBuilder boilerplate. Just declare the end state and let Flutter do the work.

Change Size, Color or Padding

The core idea is simple: AnimatedContainer watches a set of properties and animates between their old and new values. The most commonly animated properties are height, width, color, and padding. Let's walk through a practical example.

Imagine you're building a task app where tapping a task card expands it to show more details. You want a smooth height transition:

dart
class TaskCard extends StatefulWidget {
  @override
  State<TaskCard> createState() => _TaskCardState();
}

class _TaskCardState extends State<TaskCard> {
  bool isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() {
          isExpanded = !isExpanded;
        });
      },
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
        height: isExpanded ? 220 : 80,
        padding: EdgeInsets.all(isExpanded ? 20 : 12),
        decoration: BoxDecoration(
          color: isExpanded ? Colors.blue.shade50 : Colors.white,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color: isExpanded ? Colors.blue : Colors.grey.shade300,
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Task Title', style: Theme.of(context).textTheme.titleMedium),
            if (isExpanded) ...[
              const SizedBox(height: 12),
              Text('Full task description goes here'),
              const SizedBox(height: 8),
              Text('Due: Tomorrow', style: TextStyle(color: Colors.grey)),
            ]
          ],
        ),
      ),
    );
  }
}

Notice that we're changing three things in the AnimatedContainer: height, padding, and the border color. When isExpanded flips, all three properties animate smoothly to their new values. No manual listener management. No controller disposal worries. Just state change and animation.

Flutter Animates Between Values

The real magic happens under the hood. When you call setState() with a new value, AnimatedContainer detects that the property has changed and starts an animation from the old value to the new one. This is called implicit animation—the animation happens implicitly when the property changes.

This works because Flutter's animation system knows how to interpolate (smoothly transition) between different types of values. Height from 80 to 220? It moves through every pixel in between. Color from white to blue? It blends through the color space. Padding from 12 to 20? Every value in between is rendered.

The beauty is that you don't have to think about frames or percentages. You just declare where you want to end up, and Flutter handles the journey. This is dramatically simpler than using an AnimationController, which requires you to manage the animation's lifecycle manually.

Let me show you a real-world scenario from a ride-hailing app I built. We had a booking flow where the confirmation button needed to grow when tapped to show a loading state:

dart
class BookingButton extends StatefulWidget {
  @override
  State<BookingButton> createState() => _BookingButtonState();
}

class _BookingButtonState extends State<BookingButton> {
  bool isLoading = false;

  Future<void> _handleBooking() async {
    setState(() {
      isLoading = true;
    });
    
    // Simulate API call
    await Future.delayed(const Duration(seconds: 2));
    
    setState(() {
      isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
      width: isLoading ? 60 : double.infinity,
      height: 60,
      decoration: BoxDecoration(
        color: Colors.blue,
        borderRadius: BorderRadius.circular(isLoading ? 30 : 8),
      ),
      child: isLoading
          ? const CircularProgressIndicator(color: Colors.white)
          : const Text('Confirm Booking'),
    );
  }
}

This is the kind of polish that users love. The button morphs into a circle while loading. No janky state switches, just smooth interpolation between dimensions and border radius.

Set Duration and Curve

Two parameters control how the animation feels: duration and curve.

Duration is how long the animation takes. I typically use 200–300 milliseconds for small changes like color or padding, and 300–500 milliseconds for larger transformations like height or width changes. Too fast feels snappy but jarring; too slow feels sluggish. In my experience, 300ms is the sweet spot for most UI transitions.

Curve describes how the animation accelerates and decelerates. Curves.easeOut (fast start, slow end) feels natural for most expansion animations. Curves.easeIn (slow start, fast end) is great for dismissals. Curves.linear is good for progress indicators. Curves.elasticOut adds a fun bounce at the end.

dart
// Different curves for different feelings
AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeOut,  // Natural, responsive
  height: expanded ? 300 : 100,
  child: child,
);

AnimatedContainer(
  duration: const Duration(milliseconds: 400),
  curve: Curves.elasticOut,  // Playful bounce
  height: expanded ? 300 : 100,
  child: child,
);

AnimatedContainer(
  duration: const Duration(milliseconds: 600),
  curve: Curves.decelerate,  // Deliberate, slow fade
  height: expanded ? 300 : 100,
  child: child,
);

I recommend experimenting with curves during development. The visual difference is real, and what feels right for a settings panel might feel wrong for a playful game interface.

Great for Selection and Expansion States

The most natural use case for AnimatedContainer is toggling between states. Selection (is this item selected?), expansion (is this section open?), and loading states (is this button busy?) all work beautifully.

Here's a practical example from a travel app I worked on, where users select flight options:

dart
class FlightOption extends StatefulWidget {
  final String flightName;
  final String duration;
  final double price;
  final VoidCallback onSelected;

  const FlightOption({
    required this.flightName,
    required this.duration,
    required this.price,
    required this.onSelected,
  });

  @override
  State<FlightOption> createState() => _FlightOptionState();
}

class _FlightOptionState extends State<FlightOption> {
  bool isSelected = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() {
          isSelected = !isSelected;
        });
        widget.onSelected();
      },
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeOut,
        padding: EdgeInsets.all(isSelected ? 16 : 12),
        decoration: BoxDecoration(
          color: isSelected ? Colors.blue.shade50 : Colors.white,
          border: Border.all(
            color: isSelected ? Colors.blue : Colors.grey.shade300,
            width: isSelected ? 2 : 1,
          ),
          borderRadius: BorderRadius.circular(12),
          boxShadow: isSelected
              ? [BoxShadow(color: Colors.blue.withOpacity(0.2), blurRadius: 8)]
              : [],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(widget.flightName, style: Theme.of(context).textTheme.titleMedium),
            Text('${widget.duration} hours', style: Theme.of(context).textTheme.bodySmall),
            SizedBox(height: 8),
            Text('\$${widget.price.toStringAsFixed(2)}',
                style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                      fontWeight: FontWeight.bold,
                      color: isSelected ? Colors.blue : Colors.black,
                    )),
          ],
        ),
      ),
    );
  }
}

When a user taps a flight, the card animates: the background color shifts, the border thickens and changes color, the padding increases, and a subtle shadow appears. All of this happens in lockstep because it's a single AnimatedContainer responding to state change.

This pattern scales beautifully. Whether you're building a multi-select list, a collapsible menu, or a wizard with step-by-step expansion, AnimatedContainer handles it with grace.

Common Mistakes

Not setting a final height/width for row/column children. If your AnimatedContainer contains a Column or Row, Flutter needs to know its constraints. If you set height on the container but not width, layout errors can bubble up. Always be explicit about dimensions when they matter to layout.

Animating properties that don't support interpolation. AnimatedContainer works great for numeric and color properties, but things like alignment or transform won't animate smoothly. If you need those, use explicit animation controllers or widgets like AnimatedAlign and AnimatedTransform.

Using identical old and new values. If your state change doesn't actually change the container's properties, the animation still runs but you see nothing. This usually means your state logic has a bug. Test that your boolean toggles actually flip.

Forgetting to rebuild on state change. AnimatedContainer only starts animating when you call setState() (or when a provider/riverpod rebuild happens). If you change state outside of the widget tree, nothing animates. Always ensure your state updates trigger a rebuild.

Key Takeaways

  • AnimatedContainer animates between property values without manual controller boilerplate.
  • It works best for animating numeric properties (height, width, padding) and colors in response to state changes.
  • Use duration to control speed (typically 200–500ms) and curve to control the feel (Curves.easeOut for natural, Curves.elasticOut for playful).
  • Selection and expansion states are the sweet spot for AnimatedContainer—any UI that toggles between two visual states.
  • Keep your animated properties explicit; unclear constraints lead to layout surprises.
  • This is implicit animation at its finest—declare the end state and let Flutter handle the journey.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles