Implementing a Custom Ripple Animation Effect

01 Jan 2026

9K

35K

Implementing a Custom Ripple Animation Effect on a Button in Flutter

The ripple effect is a common and intuitive visual feedback mechanism in user interfaces, especially prevalent in Material Design. It provides a subtle yet engaging visual cue when a user interacts with a tappable element, indicating that the touch has been registered and an action is imminent. While Flutter's Material widgets often provide this effect out-of-the-box, understanding how to implement a custom ripple allows for greater flexibility and control over its appearance, timing, and integration with non-Material or highly customized UI components. Understanding the Default Ripple Behavior Flutter's Material library simplifies the inclusion of the ripple effect. Widgets like `InkWell`, `InkResponse`, `ElevatedButton`, `TextButton`, and `OutlinedButton` automatically display a Material Design ripple effect on tap. These widgets are built upon `Material` widget and utilize `InkFeature`s to draw the ripple. For most standard use cases, simply wrapping a widget with `InkWell` or using a Material button is sufficient. Why Implement a Custom Ripple Effect? There are several scenarios where a custom ripple implementation becomes necessary or desirable:
  • Non-Material Widgets: Applying a ripple to a custom widget that doesn't inherently support Material Design ink effects.
  • Advanced Customization: Achieving a specific look, color gradient, shape, or animation timing that the default `InkWell` or `MaterialButton` does not offer.
  • Integration with Complex Animations: When the ripple needs to be part of a larger, coordinated animation sequence.
  • Performance Optimization: In highly specific scenarios, a custom implementation might allow for finer control over rendering, though often `InkWell` is already well-optimized.
Core Concepts for Custom Ripple Implementation A custom ripple effect in Flutter typically involves combining several fundamental animation and UI concepts:
  • GestureDetector: To detect tap events (e.g., `onTapDown`) and capture the exact position of the tap.
  • AnimationController: To manage the lifecycle of the animation, including starting, stopping, and reversing it.
  • Tween: To define the range of values for animated properties (e.g., scale from 0.0 to 1.0, opacity from 1.0 to 0.0).
  • AnimatedBuilder: A widget that rebuilds its child when an `Animation` changes value, optimizing performance by only rebuilding the necessary parts of the UI.
  • Stack: To overlay the expanding ripple circle on top of the button content.
  • Positioned: Used within a `Stack` to precisely position the ripple circle at the tap location.
  • Transform.scale & Opacity: To animate the size and fade-out of the ripple circle.
Step-by-Step Implementation Example Let's walk through creating a simple custom button with a ripple effect that expands from the tap point and fades out.

import 'package:flutter/material.dart';
import 'dart:math' as math;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Ripple Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const RippleButtonDemo(),
    );
  }
}

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

  @override
  State<RippleButtonDemo> createState() => _RippleButtonDemoState();
}

class _RippleButtonDemoState extends State<RippleButtonDemo> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  Animation<double>? _scaleAnimation;
  Animation<double>? _opacityAnimation;
  Offset _tapPosition = Offset.zero;
  bool _isRippleActive = false;
  final GlobalKey _buttonKey = GlobalKey();

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

    _animationController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        setState(() {
          _isRippleActive = false; // Reset ripple when animation completes
        });
        _animationController.reset();
      }
    });
  }

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

  void _handleTapDown(TapDownDetails details) {
    final RenderBox renderBox = _buttonKey.currentContext!.findRenderObject() as RenderBox;
    _tapPosition = renderBox.globalToLocal(details.globalPosition);

    // Calculate maximum ripple radius based on button size
    final double buttonWidth = renderBox.size.width;
    final double buttonHeight = renderBox.size.height;
    final double maxRadius = math.sqrt(buttonWidth * buttonWidth + buttonHeight * buttonHeight);

    setState(() {
      _isRippleActive = true;
      _scaleAnimation = Tween(begin: 0.0, end: maxRadius / 25).animate( // Ripple circle size factor
        CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
      );
      _opacityAnimation = Tween(begin: 0.4, end: 0.0).animate(
        CurvedAnimation(parent: _animationController, curve: CurveseaseOut),
      );
    });

    _animationController.forward(from: 0.0);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Custom Ripple Button'),
      ),
      body: Center(
        child: GestureDetector(
          onTapDown: _handleTapDown,
          child: Container(
            key: _buttonKey,
            width: 200,
            height: 60,
            decoration: BoxDecoration(
              color: Colors.deepPurple,
              borderRadius: BorderRadius.circular(10),
              boxShadow: const [
                BoxShadow(
                  color: Colors.black26,
                  blurRadius: 8,
                  offset: Offset(0, 4),
                ),
              ],
            ),
            child: Stack(
              clipBehavior: Clip.hardEdge, // Clip ripples that go outside the button
              children: [
                Center(
                  child: Text(
                    'Tap Me!',
                    style: TextStyle(color: Colors.white, fontSize: 20),
                  ),
                ),
                if (_isRippleActive && _scaleAnimation != null && _opacityAnimation != null)
                  AnimatedBuilder(
                    animation: _animationController,
                    builder: (context, child) {
                      return Positioned(
                        left: _tapPosition.dx - (_scaleAnimation!.value * 25), // Adjust based on ripple size
                        top: _tapPosition.dy - (_scaleAnimation!.value * 25), // Adjust based on ripple size
                        child: Opacity(
                          opacity: _opacityAnimation!.value,
                          child: Transform.scale(
                            scale: _scaleAnimation!.value,
                            child: Container(
                              width: 50, // Base size of the ripple circle
                              height: 50, // Base size of the ripple circle
                              decoration: BoxDecoration(
                                shape: BoxShape.circle,
                                color: Colors.white.withOpacity(1.0), // Full opacity before fading
                              ),
                            ),
                          ),
                        ),
                      );
                    },
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
Code Explanation 1. **`RippleButtonDemo` Stateful Widget:** This widget manages the state of the animation and the ripple's position. 2. **`SingleTickerProviderStateMixin`:** Added to the state class to provide a `Ticker` for the `AnimationController`, preventing animations from consuming resources when off-screen. 3. **`AnimationController` Setup:** * Initialized in `initState` with a `duration` (e.g., 400 milliseconds). * An `addStatusListener` is used to reset the animation and set `_isRippleActive` to `false` once the ripple animation completes. This ensures the ripple disappears and the state is ready for the next tap. 4. **`_handleTapDown` Method:** * This is called when the user taps down on the `GestureDetector`. * It calculates `_tapPosition`: The local coordinates within the button where the tap occurred. This is crucial for positioning the ripple correctly. * `maxRadius` is calculated: This determines how large the ripple should become to cover the entire button, ensuring it appears to spread from the tap point to the edges. * `_scaleAnimation` and `_opacityAnimation` are created using `Tween`s and `CurvedAnimation` for a smooth ease-out effect. The scale `end` value is a factor of `maxRadius` relative to the base ripple `Container` size (50 in this case), ensuring it scales appropriately. * `_isRippleActive` is set to `true` to conditionally render the ripple. * `_animationController.forward(from: 0.0)` starts the animation from the beginning. 5. **`GestureDetector`:** Wraps the custom button `Container` to capture tap events. `onTapDown` is used to get the exact tap coordinates. 6. **Button `Container`:** This is our custom button. It uses a `GlobalKey` (`_buttonKey`) to later get its `RenderBox` for coordinate transformation. 7. **`Stack` Widget:** * The `Stack` allows us to layer the button's content (the "Tap Me!" text) and the ripple effect on top of each other. * `clipBehavior: Clip.hardEdge` ensures that the ripple does not extend beyond the button's boundaries. 8. **Conditional Ripple Rendering:** The `if (_isRippleActive ...)` condition ensures the `AnimatedBuilder` (and thus the ripple) is only rendered when an animation is active. 9. **`AnimatedBuilder`:** * Its `animation` property is linked to `_animationController`. * The `builder` callback rebuilds whenever `_animationController` ticks. * Inside the builder, a `Positioned` widget places the ripple. Its `left` and `top` properties are adjusted based on `_tapPosition` and the current scale of the ripple to center it at the tap point as it expands. * `Opacity` widget uses `_opacityAnimation.value` to fade the ripple out. * `Transform.scale` uses `_scaleAnimation.value` to make the ripple grow. * The ripple itself is a simple `Container` with `BoxShape.circle` and `Colors.white` for visibility, which is then scaled and faded. 10. **`dispose` Method:** The `_animationController` is disposed of to prevent memory leaks when the widget is removed from the widget tree. This custom implementation provides a clear foundation for creating unique ripple effects. You can further customize the ripple by changing its color, shape (e.g., using a `CustomPainter`), animation curves, duration, or even combining multiple ripples for more complex interactions.

Related Articles

Aug 02, 2026

Building a Flutter Notification Center with Grouping & Actions

Learn to create a sophisticated in-app notification center in Flutter, featuring grouping, swipe-to-dismiss, custom icons, and actionable buttons for enhanced u

Aug 01, 2026

Flutter Layout Tips: Using FractionallySizedBox, AspectRatio, ConstrainedBox for Adaptive UI

Master Flutter's FractionallySizedBox, AspectRatio, and ConstrainedBox to build truly adaptive UIs. Learn how to control widget sizing, maintain proportions, an

Aug 01, 2026

Flutter Product Quick View: Animation, Related Items, Badges

Learn to create a dynamic product quick view widget in Flutter, incorporating smooth animations, relevant related items, and eye-catching promo badges for an en