← Back to Blog

Flutter ScaleTransition Localization: Controlled Scaling Effects for Multilingual Apps

flutterscaletransitionanimationscalelocalizationaccessibility

Flutter ScaleTransition Localization: Controlled Scaling Effects for Multilingual Apps

ScaleTransition provides explicit control over scaling animations using an Animation controller. Unlike AnimatedScale which handles animation internally, ScaleTransition gives you precise control over timing, curves, and synchronization with other animations. This guide covers comprehensive strategies for localizing ScaleTransition widgets in Flutter multilingual applications.

Understanding ScaleTransition Localization

ScaleTransition widgets require localization for:

  • Button feedback: Interactive press effects with accessibility announcements
  • Modal dialogs: Pop-up scaling with proper focus management
  • Selection states: Visual feedback for selected items
  • Notification badges: Attention-grabbing pulse effects
  • Menu reveals: Expanding menus with localized content
  • Loading indicators: Pulsing loaders with status messages

Basic ScaleTransition with Localized Content

Start with a simple scale animation for interactive elements:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedScaleButton extends StatefulWidget {
  const LocalizedScaleButton({super.key});

  @override
  State<LocalizedScaleButton> createState() => _LocalizedScaleButtonState();
}

class _LocalizedScaleButtonState extends State<LocalizedScaleButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 150),
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

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

  void _onTapDown(TapDownDetails details) {
    _controller.forward();
  }

  void _onTapUp(TapUpDetails details) {
    _controller.reverse();
  }

  void _onTapCancel() {
    _controller.reverse();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.scaleButtonDemoTitle)),
      body: Center(
        child: GestureDetector(
          onTapDown: _onTapDown,
          onTapUp: _onTapUp,
          onTapCancel: _onTapCancel,
          onTap: () {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(l10n.buttonPressedMessage)),
            );
          },
          child: ScaleTransition(
            scale: _scaleAnimation,
            child: Semantics(
              button: true,
              label: l10n.interactiveButtonLabel,
              child: Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 32,
                  vertical: 16,
                ),
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.primary,
                  borderRadius: BorderRadius.circular(12),
                  boxShadow: [
                    BoxShadow(
                      color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
                      blurRadius: 8,
                      offset: const Offset(0, 4),
                    ),
                  ],
                ),
                child: Text(
                  l10n.pressMe,
                  style: TextStyle(
                    color: Theme.of(context).colorScheme.onPrimary,
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

ARB File Structure for ScaleTransition

{
  "scaleButtonDemoTitle": "Scale Button Demo",
  "@scaleButtonDemoTitle": {
    "description": "Title for scale button demo screen"
  },
  "buttonPressedMessage": "Button pressed!",
  "@buttonPressedMessage": {
    "description": "Message shown when button is pressed"
  },
  "interactiveButtonLabel": "Interactive button with scale effect",
  "@interactiveButtonLabel": {
    "description": "Accessibility label for interactive button"
  },
  "pressMe": "Press Me",
  "@pressMe": {
    "description": "Button text"
  }
}

Staggered Scale Animation for Lists

Create staggered pop-in effects for list items:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedStaggeredScaleList extends StatefulWidget {
  const LocalizedStaggeredScaleList({super.key});

  @override
  State<LocalizedStaggeredScaleList> createState() => _LocalizedStaggeredScaleListState();
}

class _LocalizedStaggeredScaleListState extends State<LocalizedStaggeredScaleList>
    with TickerProviderStateMixin {
  late List<AnimationController> _controllers;
  late List<Animation<double>> _scaleAnimations;

  final int _itemCount = 6;

  @override
  void initState() {
    super.initState();
    _controllers = List.generate(
      _itemCount,
      (index) => AnimationController(
        vsync: this,
        duration: const Duration(milliseconds: 300),
      ),
    );

    _scaleAnimations = _controllers.map((controller) {
      return Tween<double>(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: controller,
          curve: Curves.elasticOut,
        ),
      );
    }).toList();

    _startStaggeredAnimation();
  }

  Future<void> _startStaggeredAnimation() async {
    for (int i = 0; i < _controllers.length; i++) {
      await Future.delayed(const Duration(milliseconds: 80));
      if (mounted) {
        _controllers[i].forward();
      }
    }
  }

  Future<void> _resetAnimation() async {
    for (final controller in _controllers) {
      controller.reset();
    }
    _startStaggeredAnimation();
  }

  @override
  void dispose() {
    for (final controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final categories = [
      _Category(
        icon: Icons.work,
        title: l10n.categoryWorkTitle,
        count: l10n.taskCount(5),
        color: Colors.blue,
      ),
      _Category(
        icon: Icons.person,
        title: l10n.categoryPersonalTitle,
        count: l10n.taskCount(3),
        color: Colors.green,
      ),
      _Category(
        icon: Icons.shopping_cart,
        title: l10n.categoryShoppingTitle,
        count: l10n.taskCount(8),
        color: Colors.orange,
      ),
      _Category(
        icon: Icons.fitness_center,
        title: l10n.categoryFitnessTitle,
        count: l10n.taskCount(2),
        color: Colors.red,
      ),
      _Category(
        icon: Icons.school,
        title: l10n.categoryLearningTitle,
        count: l10n.taskCount(4),
        color: Colors.purple,
      ),
      _Category(
        icon: Icons.home,
        title: l10n.categoryHomeTitle,
        count: l10n.taskCount(6),
        color: Colors.teal,
      ),
    ];

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.categoriesTitle),
        actions: [
          IconButton(
            onPressed: _resetAnimation,
            icon: const Icon(Icons.refresh),
            tooltip: l10n.replayAnimation,
          ),
        ],
      ),
      body: GridView.builder(
        padding: const EdgeInsets.all(16),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          mainAxisSpacing: 16,
          crossAxisSpacing: 16,
        ),
        itemCount: categories.length,
        itemBuilder: (context, index) {
          final category = categories[index];
          return ScaleTransition(
            scale: _scaleAnimations[index],
            child: Semantics(
              button: true,
              label: l10n.categoryAccessibility(category.title, category.count),
              child: Card(
                clipBehavior: Clip.antiAlias,
                child: InkWell(
                  onTap: () {},
                  child: Container(
                    decoration: BoxDecoration(
                      gradient: LinearGradient(
                        colors: [
                          category.color.withOpacity(0.8),
                          category.color,
                        ],
                        begin: Alignment.topLeft,
                        end: Alignment.bottomRight,
                      ),
                    ),
                    child: Padding(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(
                            category.icon,
                            size: 48,
                            color: Colors.white,
                          ),
                          const SizedBox(height: 12),
                          Text(
                            category.title,
                            style: const TextStyle(
                              color: Colors.white,
                              fontSize: 16,
                              fontWeight: FontWeight.bold,
                            ),
                            textAlign: TextAlign.center,
                          ),
                          const SizedBox(height: 4),
                          Text(
                            category.count,
                            style: TextStyle(
                              color: Colors.white.withOpacity(0.9),
                              fontSize: 14,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

class _Category {
  final IconData icon;
  final String title;
  final String count;
  final Color color;

  _Category({
    required this.icon,
    required this.title,
    required this.count,
    required this.color,
  });
}

Pulsing Notification Badge

Create an attention-grabbing badge with pulse animation:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedPulsingBadge extends StatefulWidget {
  final int notificationCount;

  const LocalizedPulsingBadge({
    super.key,
    required this.notificationCount,
  });

  @override
  State<LocalizedPulsingBadge> createState() => _LocalizedPulsingBadgeState();
}

class _LocalizedPulsingBadgeState extends State<LocalizedPulsingBadge>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );

    if (widget.notificationCount > 0) {
      _controller.repeat(reverse: true);
    }
  }

  @override
  void didUpdateWidget(LocalizedPulsingBadge oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.notificationCount > 0 && !_controller.isAnimating) {
      _controller.repeat(reverse: true);
    } else if (widget.notificationCount == 0) {
      _controller.stop();
      _controller.reset();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Semantics(
      label: widget.notificationCount > 0
          ? l10n.notificationBadgeAccessibility(widget.notificationCount)
          : l10n.noNotifications,
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          IconButton(
            onPressed: () {},
            icon: Icon(
              Icons.notifications,
              size: 32,
              color: Theme.of(context).colorScheme.onSurface,
            ),
          ),
          if (widget.notificationCount > 0)
            Positioned(
              right: 4,
              top: 4,
              child: ScaleTransition(
                scale: _scaleAnimation,
                child: Container(
                  padding: const EdgeInsets.all(4),
                  constraints: const BoxConstraints(
                    minWidth: 20,
                    minHeight: 20,
                  ),
                  decoration: BoxDecoration(
                    color: Theme.of(context).colorScheme.error,
                    shape: BoxShape.circle,
                  ),
                  child: Center(
                    child: Text(
                      widget.notificationCount > 99
                          ? '99+'
                          : widget.notificationCount.toString(),
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.onError,
                        fontSize: 10,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class NotificationBadgeDemo extends StatefulWidget {
  const NotificationBadgeDemo({super.key});

  @override
  State<NotificationBadgeDemo> createState() => _NotificationBadgeDemoState();
}

class _NotificationBadgeDemoState extends State<NotificationBadgeDemo> {
  int _notificationCount = 3;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.notificationDemoTitle),
        actions: [
          LocalizedPulsingBadge(notificationCount: _notificationCount),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              l10n.currentNotifications(_notificationCount),
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 24),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                ElevatedButton(
                  onPressed: () {
                    setState(() => _notificationCount++);
                  },
                  child: Text(l10n.addNotification),
                ),
                const SizedBox(width: 12),
                ElevatedButton(
                  onPressed: _notificationCount > 0
                      ? () {
                          setState(() => _notificationCount--);
                        }
                      : null,
                  child: Text(l10n.removeNotification),
                ),
              ],
            ),
            const SizedBox(height: 12),
            TextButton(
              onPressed: () {
                setState(() => _notificationCount = 0);
              },
              child: Text(l10n.clearAll),
            ),
          ],
        ),
      ),
    );
  }
}

Modal Dialog with Scale Animation

Create popover dialogs with scale effects:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedScaleModal extends StatefulWidget {
  const LocalizedScaleModal({super.key});

  @override
  State<LocalizedScaleModal> createState() => _LocalizedScaleModalState();
}

class _LocalizedScaleModalState extends State<LocalizedScaleModal>
    with TickerProviderStateMixin {
  late AnimationController _scaleController;
  late AnimationController _fadeController;
  late Animation<double> _scaleAnimation;
  late Animation<double> _fadeAnimation;

  bool _isModalVisible = false;

  @override
  void initState() {
    super.initState();
    _scaleController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 250),
    );
    _fadeController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
    );

    _scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
      CurvedAnimation(parent: _scaleController, curve: Curves.easeOutBack),
    );
    _fadeAnimation = CurvedAnimation(
      parent: _fadeController,
      curve: Curves.easeOut,
    );
  }

  @override
  void dispose() {
    _scaleController.dispose();
    _fadeController.dispose();
    super.dispose();
  }

  Future<void> _showModal() async {
    setState(() => _isModalVisible = true);
    _fadeController.forward();
    _scaleController.forward();
  }

  Future<void> _hideModal() async {
    _scaleController.reverse();
    await _fadeController.reverse();
    setState(() => _isModalVisible = false);
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.scaleModalDemoTitle)),
      body: Stack(
        children: [
          Center(
            child: ElevatedButton.icon(
              onPressed: _showModal,
              icon: const Icon(Icons.celebration),
              label: Text(l10n.showCelebrationButton),
            ),
          ),
          if (_isModalVisible) ...[
            // Backdrop
            Positioned.fill(
              child: GestureDetector(
                onTap: _hideModal,
                child: FadeTransition(
                  opacity: _fadeAnimation,
                  child: Container(
                    color: Colors.black54,
                  ),
                ),
              ),
            ),
            // Modal
            Center(
              child: FadeTransition(
                opacity: _fadeAnimation,
                child: ScaleTransition(
                  scale: _scaleAnimation,
                  child: Semantics(
                    label: l10n.celebrationModalAccessibility,
                    container: true,
                    child: Card(
                      margin: const EdgeInsets.all(32),
                      child: Padding(
                        padding: const EdgeInsets.all(32),
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            const Text(
                              '🎉',
                              style: TextStyle(fontSize: 64),
                            ),
                            const SizedBox(height: 16),
                            Text(
                              l10n.congratulationsTitle,
                              style: Theme.of(context).textTheme.headlineSmall,
                            ),
                            const SizedBox(height: 8),
                            Text(
                              l10n.achievementUnlocked,
                              textAlign: TextAlign.center,
                              style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                                color: Theme.of(context).colorScheme.onSurfaceVariant,
                              ),
                            ),
                            const SizedBox(height: 24),
                            ElevatedButton(
                              onPressed: _hideModal,
                              child: Text(l10n.awesomeButton),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ],
        ],
      ),
    );
  }
}

Selectable Card with Scale Feedback

Create selectable cards with scale feedback:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedSelectableCards extends StatefulWidget {
  const LocalizedSelectableCards({super.key});

  @override
  State<LocalizedSelectableCards> createState() => _LocalizedSelectableCardsState();
}

class _LocalizedSelectableCardsState extends State<LocalizedSelectableCards> {
  int? _selectedIndex;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final plans = [
      _PlanOption(
        title: l10n.planBasicTitle,
        price: l10n.planBasicPrice,
        features: [
          l10n.featureBasic1,
          l10n.featureBasic2,
          l10n.featureBasic3,
        ],
        icon: Icons.star_border,
      ),
      _PlanOption(
        title: l10n.planProTitle,
        price: l10n.planProPrice,
        features: [
          l10n.featurePro1,
          l10n.featurePro2,
          l10n.featurePro3,
          l10n.featurePro4,
        ],
        icon: Icons.star_half,
        isRecommended: true,
      ),
      _PlanOption(
        title: l10n.planEnterpriseTitle,
        price: l10n.planEnterprisePrice,
        features: [
          l10n.featureEnterprise1,
          l10n.featureEnterprise2,
          l10n.featureEnterprise3,
          l10n.featureEnterprise4,
          l10n.featureEnterprise5,
        ],
        icon: Icons.star,
      ),
    ];

    return Scaffold(
      appBar: AppBar(title: Text(l10n.selectPlanTitle)),
      body: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: plans.length,
        itemBuilder: (context, index) {
          final plan = plans[index];
          final isSelected = _selectedIndex == index;

          return Padding(
            padding: const EdgeInsets.only(bottom: 16),
            child: _SelectablePlanCard(
              plan: plan,
              isSelected: isSelected,
              onSelect: () {
                setState(() => _selectedIndex = index);
              },
              l10n: l10n,
            ),
          );
        },
      ),
      bottomNavigationBar: _selectedIndex != null
          ? SafeArea(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: ElevatedButton(
                  onPressed: () {},
                  child: Text(l10n.continueWithPlan(plans[_selectedIndex!].title)),
                ),
              ),
            )
          : null,
    );
  }
}

class _SelectablePlanCard extends StatefulWidget {
  final _PlanOption plan;
  final bool isSelected;
  final VoidCallback onSelect;
  final AppLocalizations l10n;

  const _SelectablePlanCard({
    required this.plan,
    required this.isSelected,
    required this.onSelect,
    required this.l10n,
  });

  @override
  State<_SelectablePlanCard> createState() => _SelectablePlanCardState();
}

class _SelectablePlanCardState extends State<_SelectablePlanCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.03).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );

    if (widget.isSelected) {
      _controller.forward();
    }
  }

  @override
  void didUpdateWidget(_SelectablePlanCard oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.isSelected && !oldWidget.isSelected) {
      _controller.forward();
    } else if (!widget.isSelected && oldWidget.isSelected) {
      _controller.reverse();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scaleAnimation,
      child: Semantics(
        button: true,
        selected: widget.isSelected,
        label: widget.l10n.planAccessibility(
          widget.plan.title,
          widget.plan.price,
          widget.isSelected ? widget.l10n.selected : widget.l10n.notSelected,
        ),
        child: Card(
          clipBehavior: Clip.antiAlias,
          elevation: widget.isSelected ? 8 : 2,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
            side: widget.isSelected
                ? BorderSide(
                    color: Theme.of(context).colorScheme.primary,
                    width: 2,
                  )
                : BorderSide.none,
          ),
          child: InkWell(
            onTap: widget.onSelect,
            child: Padding(
              padding: const EdgeInsets.all(20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      Icon(
                        widget.plan.icon,
                        color: Theme.of(context).colorScheme.primary,
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Text(
                          widget.plan.title,
                          style: Theme.of(context).textTheme.titleLarge,
                        ),
                      ),
                      if (widget.plan.isRecommended)
                        Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 8,
                            vertical: 4,
                          ),
                          decoration: BoxDecoration(
                            color: Theme.of(context).colorScheme.primaryContainer,
                            borderRadius: BorderRadius.circular(12),
                          ),
                          child: Text(
                            widget.l10n.recommended,
                            style: TextStyle(
                              color: Theme.of(context).colorScheme.onPrimaryContainer,
                              fontSize: 12,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ),
                      if (widget.isSelected)
                        Icon(
                          Icons.check_circle,
                          color: Theme.of(context).colorScheme.primary,
                        ),
                    ],
                  ),
                  const SizedBox(height: 8),
                  Text(
                    widget.plan.price,
                    style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                      color: Theme.of(context).colorScheme.primary,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 16),
                  ...widget.plan.features.map((feature) => Padding(
                    padding: const EdgeInsets.only(bottom: 8),
                    child: Row(
                      children: [
                        Icon(
                          Icons.check,
                          size: 18,
                          color: Theme.of(context).colorScheme.primary,
                        ),
                        const SizedBox(width: 8),
                        Expanded(child: Text(feature)),
                      ],
                    ),
                  )),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _PlanOption {
  final String title;
  final String price;
  final List<String> features;
  final IconData icon;
  final bool isRecommended;

  _PlanOption({
    required this.title,
    required this.price,
    required this.features,
    required this.icon,
    this.isRecommended = false,
  });
}

Floating Action Button Menu

Create an expanding FAB menu with scale animations:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedFabMenu extends StatefulWidget {
  const LocalizedFabMenu({super.key});

  @override
  State<LocalizedFabMenu> createState() => _LocalizedFabMenuState();
}

class _LocalizedFabMenuState extends State<LocalizedFabMenu>
    with TickerProviderStateMixin {
  late AnimationController _menuController;
  late List<Animation<double>> _itemAnimations;
  late Animation<double> _rotationAnimation;

  bool _isMenuOpen = false;

  @override
  void initState() {
    super.initState();
    _menuController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 250),
    );

    _rotationAnimation = Tween<double>(begin: 0, end: 0.125).animate(
      CurvedAnimation(parent: _menuController, curve: Curves.easeOut),
    );

    _itemAnimations = List.generate(3, (index) {
      final start = index * 0.1;
      final end = start + 0.6;
      return Tween<double>(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: _menuController,
          curve: Interval(start, end.clamp(0.0, 1.0), curve: Curves.easeOutBack),
        ),
      );
    });
  }

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

  void _toggleMenu() {
    setState(() {
      _isMenuOpen = !_isMenuOpen;
      if (_isMenuOpen) {
        _menuController.forward();
      } else {
        _menuController.reverse();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final menuItems = [
      _FabMenuItem(
        icon: Icons.photo,
        label: l10n.fabAddPhoto,
        color: Colors.green,
        onTap: () {
          _toggleMenu();
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(l10n.photoActionTriggered)),
          );
        },
      ),
      _FabMenuItem(
        icon: Icons.edit,
        label: l10n.fabAddNote,
        color: Colors.blue,
        onTap: () {
          _toggleMenu();
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(l10n.noteActionTriggered)),
          );
        },
      ),
      _FabMenuItem(
        icon: Icons.mic,
        label: l10n.fabAddVoice,
        color: Colors.orange,
        onTap: () {
          _toggleMenu();
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(l10n.voiceActionTriggered)),
          );
        },
      ),
    ];

    return Scaffold(
      appBar: AppBar(title: Text(l10n.fabMenuDemoTitle)),
      body: Center(
        child: Text(l10n.fabMenuInstructions),
      ),
      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ...List.generate(menuItems.length, (index) {
            final reversedIndex = menuItems.length - 1 - index;
            final item = menuItems[reversedIndex];

            return Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: ScaleTransition(
                scale: _itemAnimations[reversedIndex],
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    if (_isMenuOpen)
                      FadeTransition(
                        opacity: _itemAnimations[reversedIndex],
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 12,
                            vertical: 6,
                          ),
                          margin: const EdgeInsets.only(right: 8),
                          decoration: BoxDecoration(
                            color: Theme.of(context).colorScheme.surface,
                            borderRadius: BorderRadius.circular(4),
                            boxShadow: [
                              BoxShadow(
                                color: Colors.black.withOpacity(0.1),
                                blurRadius: 4,
                              ),
                            ],
                          ),
                          child: Text(
                            item.label,
                            style: Theme.of(context).textTheme.bodySmall,
                          ),
                        ),
                      ),
                    Semantics(
                      button: true,
                      label: item.label,
                      child: FloatingActionButton.small(
                        heroTag: 'fab_$index',
                        backgroundColor: item.color,
                        onPressed: item.onTap,
                        child: Icon(item.icon, color: Colors.white),
                      ),
                    ),
                  ],
                ),
              ),
            );
          }),
          Semantics(
            button: true,
            label: _isMenuOpen ? l10n.closeMenu : l10n.openMenu,
            child: RotationTransition(
              turns: _rotationAnimation,
              child: FloatingActionButton(
                onPressed: _toggleMenu,
                child: const Icon(Icons.add),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _FabMenuItem {
  final IconData icon;
  final String label;
  final Color color;
  final VoidCallback onTap;

  _FabMenuItem({
    required this.icon,
    required this.label,
    required this.color,
    required this.onTap,
  });
}

Complete ARB File for ScaleTransition

{
  "@@locale": "en",

  "scaleButtonDemoTitle": "Scale Button Demo",
  "buttonPressedMessage": "Button pressed!",
  "interactiveButtonLabel": "Interactive button with scale effect",
  "pressMe": "Press Me",

  "categoriesTitle": "Categories",
  "replayAnimation": "Replay animation",
  "categoryWorkTitle": "Work",
  "categoryPersonalTitle": "Personal",
  "categoryShoppingTitle": "Shopping",
  "categoryFitnessTitle": "Fitness",
  "categoryLearningTitle": "Learning",
  "categoryHomeTitle": "Home",
  "taskCount": "{count, plural, =0{No tasks} =1{1 task} other{{count} tasks}}",
  "@taskCount": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  "categoryAccessibility": "{title} category, {count}",
  "@categoryAccessibility": {
    "placeholders": {
      "title": {"type": "String"},
      "count": {"type": "String"}
    }
  },

  "notificationDemoTitle": "Notification Badge",
  "notificationBadgeAccessibility": "{count, plural, =1{1 unread notification} other{{count} unread notifications}}",
  "@notificationBadgeAccessibility": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  "noNotifications": "No notifications",
  "currentNotifications": "{count} notifications",
  "@currentNotifications": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  "addNotification": "Add",
  "removeNotification": "Remove",
  "clearAll": "Clear All",

  "scaleModalDemoTitle": "Scale Modal Demo",
  "showCelebrationButton": "Celebrate!",
  "celebrationModalAccessibility": "Celebration dialog",
  "congratulationsTitle": "Congratulations!",
  "achievementUnlocked": "You've unlocked a new achievement. Keep up the great work!",
  "awesomeButton": "Awesome!",

  "selectPlanTitle": "Select Your Plan",
  "planBasicTitle": "Basic",
  "planBasicPrice": "$9/month",
  "featureBasic1": "5 projects",
  "featureBasic2": "Basic analytics",
  "featureBasic3": "Email support",
  "planProTitle": "Professional",
  "planProPrice": "$29/month",
  "featurePro1": "Unlimited projects",
  "featurePro2": "Advanced analytics",
  "featurePro3": "Priority support",
  "featurePro4": "Team collaboration",
  "planEnterpriseTitle": "Enterprise",
  "planEnterprisePrice": "$99/month",
  "featureEnterprise1": "Unlimited everything",
  "featureEnterprise2": "Custom integrations",
  "featureEnterprise3": "Dedicated manager",
  "featureEnterprise4": "SLA guarantee",
  "featureEnterprise5": "On-premise option",
  "recommended": "Recommended",
  "selected": "selected",
  "notSelected": "not selected",
  "planAccessibility": "{title} plan, {price}, {state}",
  "@planAccessibility": {
    "placeholders": {
      "title": {"type": "String"},
      "price": {"type": "String"},
      "state": {"type": "String"}
    }
  },
  "continueWithPlan": "Continue with {plan}",
  "@continueWithPlan": {
    "placeholders": {
      "plan": {"type": "String"}
    }
  },

  "fabMenuDemoTitle": "FAB Menu Demo",
  "fabMenuInstructions": "Tap the floating button to see the menu",
  "fabAddPhoto": "Add Photo",
  "fabAddNote": "Add Note",
  "fabAddVoice": "Voice Memo",
  "photoActionTriggered": "Opening camera...",
  "noteActionTriggered": "Creating new note...",
  "voiceActionTriggered": "Starting voice recording...",
  "openMenu": "Open menu",
  "closeMenu": "Close menu"
}

Best Practices Summary

  1. Use explicit controllers: ScaleTransition requires Animation from an AnimationController
  2. Choose appropriate scale values: 0.95-1.0 for press feedback, 0.0-1.0 for appear/disappear
  3. Dispose controllers properly: Always dispose AnimationControllers in the dispose method
  4. Add accessibility announcements: Use Semantics to announce scale state changes
  5. Use elastic curves sparingly: Curves.elasticOut creates bouncy effects but can feel excessive
  6. Combine with other transitions: ScaleTransition works well with FadeTransition and RotationTransition
  7. Handle tap gestures: Use onTapDown/onTapUp/onTapCancel for interactive press effects
  8. Consider performance: Avoid scaling very large or complex widgets frequently
  9. Test with screen readers: Ensure scaled content is properly announced
  10. Maintain touch targets: Don't scale interactive elements below minimum touch size (48px)

Conclusion

ScaleTransition provides precise control over scaling animations in Flutter apps. By using explicit AnimationControllers, you can create sophisticated pop-in effects, interactive feedback, pulsing badges, and expanding menus that enhance the user experience across all languages. The key is combining proper accessibility support with well-timed animations that feel natural and responsive.

Remember to always dispose of your AnimationControllers and test your scale animations with various screen readers to ensure all users can perceive the visual changes.