image

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

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is