Flutter Custom Dialog Animations: Practical Patterns and Reusable Code

Build polished Flutter custom dialog animations with showGeneralDialog: fade-scale, slide-up, and 3D flip patterns, plus reusable code and tips.

ASOasis
7 min read
Flutter Custom Dialog Animations: Practical Patterns and Reusable Code

Image used for representation purposes only.

Why custom dialog animations matter

Dialog windows are small, but they carry big moments: confirming destructive actions, capturing a quick input, showcasing success states. A sharp animation makes these moments feel intentional, polished, and fast—without overwhelming users. In Flutter, you aren’t limited to the default fade; you can craft expressive, brand-aligned motion with only a few lines of code.

This guide shows practical patterns for custom dialog animations using showGeneralDialog, plus reusable helpers, accessibility considerations, and performance/testing tips.

The building block: showGeneralDialog

Flutter’s showDialog is great for defaults. When you need full control over timing, curves, and transitions, reach for showGeneralDialog. It exposes:

  • pageBuilder: builds the dialog’s content.
  • transitionBuilder: animates the entry/exit of that content.
  • barrierColor, barrierDismissible, transitionDuration: control behavior and feel.

Minimal example:

import 'package:flutter/material.dart';

void openBasicDialog(BuildContext context) {
  showGeneralDialog(
    context: context,
    barrierColor: Colors.black54,
    barrierDismissible: true,
    barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
    transitionDuration: const Duration(milliseconds: 250),
    pageBuilder: (context, anim, secondaryAnim) {
      return SafeArea(
        child: Align(
          alignment: Alignment.center,
          child: _BasicDialog(),
        ),
      );
    },
    transitionBuilder: (context, anim, secondaryAnim, child) {
      final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutCubic);
      return FadeTransition(
        opacity: curved,
        child: ScaleTransition(
          scale: Tween<double>(begin: 0.95, end: 1).animate(curved),
          child: child,
        ),
      );
    },
  );
}

The transitionBuilder receives the child from pageBuilder for performance—always return it inside your animated wrappers.

A reusable helper: showAnimatedDialog

Let’s wrap common options in a single function and support multiple transition styles.

import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' as vmath;

enum DialogTransition { fadeScale, slideUp, flip3D }

Future<T?> showAnimatedDialog<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  DialogTransition transition = DialogTransition.fadeScale,
  Curve curve = Curves.easeOutCubic,
  Duration duration = const Duration(milliseconds: 300),
  bool barrierDismissible = true,
  Color barrierColor = Colors.black54,
  String? barrierLabel,
  Alignment alignment = Alignment.center,
}) {
  final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
  final effectiveDuration = reduceMotion ? const Duration(milliseconds: 1) : duration;

  return showGeneralDialog<T>(
    context: context,
    barrierDismissible: barrierDismissible,
    barrierLabel: barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel,
    barrierColor: barrierColor,
    transitionDuration: effectiveDuration,
    reverseTransitionDuration: effectiveDuration,
    pageBuilder: (context, anim, secondaryAnim) {
      return SafeArea(
        child: Align(
          alignment: alignment,
          child: builder(context),
        ),
      );
    },
    transitionBuilder: (context, anim, secondaryAnim, child) {
      if (reduceMotion) return child;

      final curved = CurvedAnimation(
        parent: anim,
        curve: curve,
        reverseCurve: Curves.easeInCubic,
      );

      switch (transition) {
        case DialogTransition.fadeScale:
          return FadeTransition(
            opacity: curved,
            child: ScaleTransition(
              scale: Tween<double>(begin: 0.95, end: 1).animate(curved),
              child: child,
            ),
          );
        case DialogTransition.slideUp:
          final offset = Tween<Offset>(begin: const Offset(0, 0.1), end: Offset.zero)
              .animate(curved);
          return FadeTransition(
            opacity: curved,
            child: SlideTransition(position: offset, child: child),
          );
        case DialogTransition.flip3D:
          return AnimatedBuilder(
            animation: curved,
            builder: (context, _) {
              final angle = (1 - curved.value) * -math.pi / 2; // -90° to 0°
              final m = vmath.Matrix4.identity()
                ..setEntry(3, 2, 0.001) // perspective
                ..rotateX(angle);
              return Opacity(
                opacity: curved.value.clamp(0.0, 1.0),
                child: Transform(
                  alignment: Alignment.center,
                  transform: m,
                  child: child,
                ),
              );
            },
          );
      }
    },
  );
}

Key ideas:

  • Reduce motion: respects system accessibility by collapsing durations.
  • reverseCurve: makes dismiss feel snappier than entrance.
  • alignment: lets you anchor dialogs top/bottom for different moods.

Pattern 1: Fade + scale (polished default)

This reads as “material pops into focus,” great for confirmations and alerts.

ElevatedButton(
  onPressed: () => showAnimatedDialog(
    context: context,
    transition: DialogTransition.fadeScale,
    builder: (_) => const _BasicDialog(),
  ),
  child: const Text('Open dialog'),
)

Pattern 2: Slide-up (sheet-like dialog)

Slides from the bottom while fading in—ideal for lightweight pickers or contextual actions.

await showAnimatedDialog(
  context: context,
  transition: DialogTransition.slideUp,
  alignment: Alignment.bottomCenter,
  barrierColor: Colors.black.withOpacity(0.45),
  curve: Curves.easeOutQuart,
  builder: (_) => const _BasicDialog(),
);

Tip: Pair with a rounded top edge and extra bottom padding to mimic a compact modal sheet without switching to showModalBottomSheet.

Pattern 3: 3D flip (delight, used sparingly)

A subtle card flip can be memorable for success states or onboarding hints. Keep perspective low and motion quick.

await showAnimatedDialog(
  context: context,
  transition: DialogTransition.flip3D,
  duration: const Duration(milliseconds: 380),
  barrierDismissible: false, // encourage a decisive action
  builder: (_) => const _BasicDialog(title: 'Welcome', message: 'Let’s get started!'),
);

Building the dialog card

Separate your motion (route) from your content (card). Keep the card lightweight, responsive, and theme-aware.

class _BasicDialog extends StatelessWidget {
  const _BasicDialog({super.key, this.title = 'Delete item?', this.message = 'This action cannot be undone.'});
  final String title;
  final String message;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Material(
      color: theme.colorScheme.surface,
      elevation: 12,
      shadowColor: Colors.black26,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
      clipBehavior: Clip.antiAlias,
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 420),
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(title, style: theme.textTheme.titleLarge),
              const SizedBox(height: 8),
              Text(message, style: theme.textTheme.bodyMedium),
              const SizedBox(height: 16),
              Row(
                mainAxisAlignment: MainAxisAlignment.end,
                children: [
                  TextButton(
                    onPressed: () => Navigator.of(context).pop(false),
                    child: const Text('Cancel'),
                  ),
                  const SizedBox(width: 8),
                  FilledButton(
                    onPressed: () => Navigator.of(context).pop(true),
                    child: const Text('Confirm'),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Notes:

  • ConstrainedBox caps width on tablets/desktops.
  • shape + clipBehavior ensures rounded corners and no overflow during scale transitions.

Optional: Backdrop blur and “glass” effect

You can blur the background for a premium look. Because the barrier is separate, create the blur inside transitionBuilder as a full-screen layer behind the animated dialog.

transitionBuilder: (context, anim, _, child) {
  final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
  return Stack(
    children: [
      // Blur + tint layer
      AnimatedBuilder(
        animation: anim,
        builder: (_, __) => BackdropFilter(
          filter: ImageFilter.blur(sigmaX: 10 * anim.value, sigmaY: 10 * anim.value),
          child: Container(color: Colors.black.withOpacity(0.15 * anim.value)),
        ),
      ),
      // The dialog animation
      FadeTransition(
        opacity: fade,
        child: ScaleTransition(
          scale: Tween(begin: 0.97, end: 1.0).animate(fade),
          child: child,
        ),
      ),
    ],
  );
}

Use moderate blur (<= 12 sigma) to avoid excessive GPU cost, especially on low-end devices.

Staggered elements inside the dialog

Animate the dialog in, then cascade its internal elements for extra refinement.

class _StaggeredDialogContent extends StatefulWidget {
  const _StaggeredDialogContent({super.key});
  @override
  State<_StaggeredDialogContent> createState() => _StaggeredDialogContentState();
}

class _StaggeredDialogContentState extends State<_StaggeredDialogContent>
    with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 450),
  )..forward();

  @override
  void dispose() { _c.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    Widget item(int i, Widget child) => FadeTransition(
          opacity: CurvedAnimation(parent: _c, curve: Interval(0.1 * i, 0.6 + 0.1 * i, curve: Curves.easeOut)),
          child: SlideTransition(
            position: Tween<Offset>(begin: const Offset(0, 0.05), end: Offset.zero)
                .animate(CurvedAnimation(parent: _c, curve: Interval(0.1 * i, 0.6 + 0.1 * i, curve: Curves.easeOut))),
            child: child,
          ),
        );

    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        item(0, Text('Invite teammate', style: Theme.of(context).textTheme.titleLarge)),
        const SizedBox(height: 8),
        item(1, const Text('Send an invitation to join your project.')),
        const SizedBox(height: 12),
        item(2, const TextField(decoration: InputDecoration(labelText: 'Email'))),
        const SizedBox(height: 16),
        Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            item(3, TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel'))),
            const SizedBox(width: 8),
            item(4, FilledButton(onPressed: () {}, child: const Text('Send'))),
          ],
        ),
      ],
    );
  }
}

Compose it into your card:

builder: (_) => Material(
  color: Theme.of(context).colorScheme.surface,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
  clipBehavior: Clip.antiAlias,
  child: const Padding(padding: EdgeInsets.all(20), child: _StaggeredDialogContent()),
)

Accessibility, focus, and UX guidelines

  • Barrier behavior: Use barrierDismissible: false for destructive confirmations; true for passive/toast-like dialogs.
  • Keyboard and focus: Wrap the primary action in an autofocus Button to support keyboard/TV. Ensure focus returns to the opener on pop.
  • Screen readers: Provide descriptive titles and concise messages. Avoid animating text size/layout aggressively.
  • Reduce motion: Honor system preferences (MediaQuery.maybeOf(context)?.disableAnimations). Fall back to super-short durations.
  • Hit targets: Keep buttons at least 44×44 logical pixels.

Performance tips

  • Prefer lightweight transitions: FadeTransition, ScaleTransition, SlideTransition are GPU-cheap.
  • Blur and 3D transforms: Use sparingly; clamp sigma and duration.
  • Avoid rebuilds: Build the dialog once; animate with cheap widgets. Keep state local via StatefulBuilder or a small StatefulWidget inside the card.
  • Clip only when needed: clipBehavior costs; combine with rounded shapes to prevent overdraw.
  • Constrain width: Prevent giant layouts on desktop/tablets to reduce layout work.

Testing animated dialogs

Animations can make tests flaky if you wait on long durations. Keep durations tiny in tests or pump frames manually.

// Example: Open and assert contents quickly
await tester.tap(find.text('Open dialog'));
await tester.pump(); // start animation
await tester.pump(const Duration(milliseconds: 1)); // skip almost all motion
expect(find.text('Delete item?'), findsOneWidget);

// Close
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();

Alternatively, inject duration: const Duration(milliseconds: 1) into showAnimatedDialog in test builds.

Troubleshooting

  • The dialog appears but doesn’t animate: Ensure you return the provided child from transitionBuilder inside your animated wrappers.
  • Weird clipping during scale: Set clipBehavior: Clip.antiAlias on your Material and provide a shape.
  • Content jumps when keyboard shows: Wrap in SafeArea and consider padding for viewInsets via MediaQuery to accommodate the keyboard.
  • Dismissal feels sluggish: Use a snappier reverseCurve (e.g., Curves.easeInCubic) and slightly shorter reverseTransitionDuration.

Wrap-up

With showGeneralDialog and a small helper, you can ship dialogs that feel intentional and on-brand: from subtle fade-scale, to sheet-like slides, to tasteful 3D flips. Keep motion purposeful, respect accessibility, and test with short durations. Your dialogs will feel fast, delightful, and reliable across mobile, desktop, and the web.

Related Posts