Flutter: Building a Overlay with Transparent Cutout using CustomPainter

TL;DR: Show me the code

Who has time to read blogs anyways? You can git-clone a working example app from here and copy-paste what you need into your own app.

Go to GitHub repo

# Or just copy paste these commands
git clone https://github.com/helgesverre/flutter-overlay-demo.git
cd flutter-overlay-demo
flutter pub get
flutter run

Works on iOS, Android, and macOS. If you find it helpful, consider starring the repo ⭐️

Back to the article nobody will read.

Introduction

When building Flutter applications, you sometimes need to create an overlay with a transparent "hole" that highlights a specific widget while dimming everything else. This is particularly useful for feature tours, tooltips, or drawing attention to specific UI elements.

In this guide, we'll create a position-aware overlay that can anchor itself to any widget on screen while showing a transparent cutout around it. We'll use Flutter's CustomPainter and OverlayEntry to achieve this effect, complete with smart positioning and smooth transitions.

We'll build:

  1. A service to manage overlay state (AnchoredOverlayService)
  2. A widget that positions itself relative to an anchor rectangle
  3. A CustomPainter implementation for the transparent cutout effect
  4. Smart positioning logic to handle screen edges

Visual Example

Below is a quick visual reference of what we’ll be building:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

Day

This is an example of an anchored overlay with a backdrop cutout.

Hover over any day to see the overlay effect.
Notice how the backdrop dims the entire calendar except for the hovered day cell.

And here is how it looks like in the example flutter app.

Flutter Overlay Demo

Why Use a Custom Overlay?

  • Contextual Menus: Show options adjacent to an icon or cell.
  • Tutorial Highlights: Darken the screen, highlight a key area, and provide instructions.
  • Tooltips: Give extra info when hovering or tapping an element.

Backdrop Cutout: A translucent “mask” draws attention to a specific widget, while the rest of the screen remains visible but dimmed. This is especially useful for guided tours or features you want to highlight.


Overview of Our Approach

We’ll create:

  • AnchoredOverlayService (singleton):

  • Manages the active OverlayEntry (so only one overlay can be visible at a time).

  • Provides methods to show/hide the overlay.

  • AnchoredOverlayWidget:

  • The actual overlay widget that positions itself on the screen relative to a Rect (the anchor).

  • Manages fade-in/fade-out animation.

  • CutoutBackgroundPainter:

  • A custom painter that paints a semi-transparent background minus a rectangular “cutout” where the anchor is.

  • Smart Positioning:

  • We detect if the anchor is in a “center zone” or near the left/right edges of the screen.

  • Align the overlay to avoid off-screen overflow (clamping).


1. AnchoredOverlayService

This service ensures we only have one “anchored overlay” open at a time. It stores a reference to the active overlay and handles visibility changes.

import 'package:flutter/material.dart';

/// A singleton service that manages a single anchored overlay at a time.
/// This is helpful so you can easily show/hide an overlay from anywhere
/// in your application without juggling multiple overlay entries.
class AnchoredOverlayService {
  // Singleton boilerplate: always reference AnchoredOverlayService() to get instance
  static final AnchoredOverlayService _instance = AnchoredOverlayService._();
  factory AnchoredOverlayService() => _instance;
  AnchoredOverlayService._();

  /// The currently active overlay entry, if any.
  OverlayEntry? _currentOverlay;

  /// A ValueNotifier that we listen to for animating
  /// the overlay in or out (controls its opacity).
  final ValueNotifier<bool> _visibilityNotifier = ValueNotifier<bool>(false);

  /// Show an overlay anchored at [anchorRect], containing the widget [overlayContent].
  /// If there's already an overlay, it's removed before showing a new one.
  void showOverlay(
    BuildContext context,
    Widget overlayContent,
    Rect anchorRect,
  ) {
    // Remove any existing overlay so we only have one at a time
    _currentOverlay?.remove();
    _visibilityNotifier.value = false; // reset to hidden

    final overlay = Overlay.of(context);
    _currentOverlay = OverlayEntry(
      builder: (context) => AnchoredOverlayWidget(
        anchorRect: anchorRect,
        onDismiss: hideOverlay,
        visibilityNotifier: _visibilityNotifier,
        child: overlayContent,
      ),
    );

    // Insert our new overlay entry into the Overlay
    overlay.insert(_currentOverlay!);
  }

  /// Hide the current overlay by toggling visibility, waiting for
  /// a fade-out animation, then removing it from the Overlay.
  Future<void> hideOverlay() async {
    _visibilityNotifier.value = false;
    // Wait briefly so fade-out can complete before removing
    await Future.delayed(const Duration(milliseconds: 200));
    _currentOverlay?.remove();
    _currentOverlay = null;
  }

  /// Call dispose() if you're completely done with the service
  /// and won't be using it anymore, for example on app shutdown.
  void dispose() {
    _visibilityNotifier.dispose();
  }
}

Key Points

  1. _visibilityNotifier toggles between true (visible) and false (hidden), used by the overlay widget’s animated opacity.
  2. showOverlay() replaces any existing overlay to avoid duplicates.
  3. hideOverlay() animates out by setting _visibilityNotifier.value = false and then removing the entry once the fade-out finishes.

2. AnchoredOverlayWidget

This widget is placed in the Overlay. It:

  • Positions itself using the Rect you pass in (anchorRect).
  • Shows a cutout in the background.
  • Animates into view (fade-in) when mounted, and animates out on dismissal.
import 'package:flutter/material.dart';

/// A stateful widget that shows:
/// 1) A semi-transparent backdrop with a rectangular hole where [anchorRect] is.
/// 2) A child widget positioned near [anchorRect].
/// 3) Highlight/border for the anchor itself, if desired.
class AnchoredOverlayWidget extends StatefulWidget {
  /// The content to display in the overlay (e.g., tooltip, context menu).
  final Widget child;

  /// Callback to dismiss/hide the overlay (invoked on outside tap).
  final VoidCallback onDismiss;

  /// The global position and size (in screen coordinates) where the overlay is anchored.
  final Rect anchorRect;

  /// A ValueNotifier that indicates whether the overlay should be visible or hidden (for fade animation).
  final ValueNotifier<bool> visibilityNotifier;

  const AnchoredOverlayWidget({
    required this.child,
    required this.onDismiss,
    required this.anchorRect,
    required this.visibilityNotifier,
    Key? key,
  }) : super(key: key);

  
  State<AnchoredOverlayWidget> createState() => _AnchoredOverlayWidgetState();
}

class _AnchoredOverlayWidgetState extends State<AnchoredOverlayWidget> {
  // For demonstration, we’ll fix the overlay’s width at 200px
  static const double overlayWidth = 200.0;

  // Small margin so the overlay doesn’t hug screen edges
  static const double horizontalPadding = 12.0;
  static const double verticalPadding = 12.0;

  // We'll define a center zone that is 30% of the screen width
  // to decide if we position overlay in the middle or to left/right
  static const double centerZonePercentage = 0.30;

  
  void initState() {
    super.initState();
    // We trigger the fade-in after the first frame has rendered
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => widget.visibilityNotifier.value = true,
    );
  }

  
  Widget build(BuildContext context) {
    // We use ValueListenableBuilder so the widget can rebuild
    // whenever visibility changes (for fade animation).
    return ValueListenableBuilder<bool>(
      valueListenable: widget.visibilityNotifier,
      builder: (context, isVisible, _) {
        // We'll use LayoutBuilder to measure screen dimensions
        return LayoutBuilder(builder: (context, constraints) {
          // Screen size used for positioning logic
          final screenSize = MediaQuery.of(context).size;
          final screenCenterX = screenSize.width / 2;

          // Anchor rectangle from the widget property
          final anchor = widget.anchorRect;

          // Compute the "center zone" horizontally:
          final centerZoneWidth = screenSize.width * centerZonePercentage;
          final centerZoneStart = screenCenterX - (centerZoneWidth / 2);
          final centerZoneEnd = screenCenterX + (centerZoneWidth / 2);

          // Check if anchor's center is within the "center zone"
          final isAnchorCenterZone = (anchor.center.dx >= centerZoneStart) &&
              (anchor.center.dx <= centerZoneEnd);

          // Calculate available space on left and right of the anchor
          final availableSpaceLeft = anchor.left;
          final availableSpaceRight = screenSize.width - anchor.right;

          // Decide final horizontal position for the overlay so it won't overflow
          final leftPosition = _calculateOverlayLeft(
            anchor,
            isAnchorCenterZone,
            screenCenterX,
            availableSpaceLeft,
            availableSpaceRight,
            screenSize,
          );

          return AnimatedOpacity(
            // Fade in/out over 200 ms
            duration: const Duration(milliseconds: 200),
            curve: Curves.easeInOutQuad,
            opacity: isVisible ? 1.0 : 0.0,
            // Dismiss when user taps anywhere on the backdrop
            child: GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: widget.onDismiss,
              child: Stack(
                children: [
                  // 1) Fullscreen backdrop with a cutout "hole"
                  Positioned.fill(
                    child: CustomPaint(
                      painter: CutoutBackgroundPainter(
                        holeRect: anchor,
                        backgroundColor: Colors.black54,
                      ),
                    ),
                  ),

                  // 2) The actual overlay box (tooltip/menu/etc.)
                  Positioned(
                    left: leftPosition,
                    // Position the overlay directly below the anchor
                    top: anchor.bottom + verticalPadding,
                    child: Material(
                      color: Colors.transparent,
                      elevation: 4,
                      child: SizedBox(
                        width: overlayWidth,
                        child: widget.child,
                      ),
                    ),
                  ),

                  // 3) Optionally highlight the anchor region itself
                  Positioned.fromRect(
                    rect: anchor,
                    child: IgnorePointer(
                    child: Container(
                      decoration: BoxDecoration(
                        border: Border.all(
                          color: Colors.cyan,
                          width: 4,
                            // Places border on outside so it doesn't overlap anchor content
                          strokeAlign: BorderSide.strokeAlignOutside,
                        ),
                        borderRadius: BorderRadius.circular(6),
                      ),
                    ),
                  ),
                  ),
                ],
              ),
            ),
          );
        });
      },
    );
  }

  /// Determines the final "left" coordinate for the overlay
  /// to avoid overlapping screen edges and to handle center alignment logic.
  double _calculateOverlayLeft(
    Rect anchor,
    bool isAnchorCenterZone,
    double screenCenterX,
    double availableSpaceLeft,
    double availableSpaceRight,
    Size screenSize,
  ) {
    double leftPosition;

    if (isAnchorCenterZone) {
      // If anchor is in center zone, center the overlay horizontally on the anchor
      leftPosition = anchor.center.dx - (overlayWidth / 2);
    } else if (anchor.center.dx > screenCenterX) {
      // Anchor is on the right half of the screen
      // Check if we have enough room to shift the overlay to the left of the anchor
      if (overlayWidth <= anchor.width + availableSpaceLeft) {
        // Place the overlay so that its right edge aligns with anchor's right edge
        leftPosition = anchor.right - overlayWidth;
      } else {
        // If there's not enough space, fallback to the anchor's left side
        leftPosition = anchor.left;
      }
    } else {
      // Anchor is on the left half of the screen
      // Check if there's enough space to place the overlay to the right of the anchor
      if (overlayWidth <= anchor.width + availableSpaceRight) {
        leftPosition = anchor.left;
      } else {
        // If not enough space, align it to the anchor's right edge
        leftPosition = anchor.right - overlayWidth;
      }
    }

    // Finally, clamp so we never exceed screen boundaries
    return leftPosition.clamp(
      horizontalPadding,
      screenSize.width - overlayWidth - horizontalPadding,
    );
  }
}

Smart Positioning Explanation

The _calculateOverlayLeft method demonstrates “smart positioning”:

  1. Center Zone
  • We define a middle portion of the screen (30% width) as “center zone.” If the anchor’s center falls here, we center the overlay so it appears directly above/below the anchor.
  1. Left/Right Overflow Checks
  • If the anchor is on the right side of the screen (anchor.center.dx > screen’s center), we prefer to align the overlay so it appears on the left side of the anchor—unless we don’t have enough space there, in which case we invert logic.
  1. Clamp
  • We limit the final leftPosition to be within [horizontalPadding, screenWidth - overlayWidth - horizontalPadding] to ensure it never drifts off-screen.

3. The Cutout Painter

We create a CustomPainter that draws a full-screen rect plus a hole (holeRect) subtracted from it.

import 'package:flutter/material.dart';

/// Custom painter that draws a semi-transparent background
/// with a rectangular hole at [holeRect], creating a spotlight effect.
class CutoutBackgroundPainter extends CustomPainter {
  /// The region we want to exclude (i.e. make transparent).
  final Rect holeRect;

  /// The color of the darkened backdrop.
  final Color backgroundColor;

  CutoutBackgroundPainter({
    required this.holeRect,
    required this.backgroundColor,
  });

  
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.fill;

    // Path covering the entire screen
    final screenPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));

    // Path for the hole we want to cut out
    final holePath = Path()..addRect(holeRect);

    // Subtract the hole path from the screen path
    final finalPath = Path.combine(
      PathOperation.difference,
      screenPath,
      holePath,
    );

    // Draw the resulting shape
    canvas.drawPath(finalPath, paint);
  }

  
  bool shouldRepaint(CutoutBackgroundPainter oldDelegate) {
    // Repaint if hole or color changes
    return oldDelegate.holeRect != holeRect ||
        oldDelegate.backgroundColor != backgroundColor;
  }
}

Usage Example: Highlighting a Calendar Cell

See full code sample here

Cutout Hole

Below is a simplified example that uses our new AnchoredOverlayService to show an overlay when a user taps/long-presses a “calendar cell.”

import 'package:flutter/material.dart';

class CalendarCell extends StatelessWidget {
  final int day;

  const CalendarCell({required this.day, Key? key}) : super(key: key);

  void _highlightCell(BuildContext context) {
    // 1) Get the cell's global position
    final renderObject = context.findRenderObject() as RenderBox;
    final offset = renderObject.localToGlobal(Offset.zero);

    // 2) Convert that to a Rect that includes the cell's size
    final cellRect = Rect.fromLTWH(
      offset.dx,
      offset.dy,
      renderObject.size.width,
      renderObject.size.height,
    );

    // 3) Show the overlay using our service
    AnchoredOverlayService().showOverlay(
      context,
      // The "child" to show inside the overlay
      CalendarOverlayContent(day: day),
      // The anchor rectangle
      cellRect,
    );
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => _highlightCell(context),
      onLongPress: () => _highlightCell(context),
      child: Container(
        decoration: BoxDecoration(
          color: Colors.grey.shade50,
          border: Border.all(color: Colors.grey.shade300),
          borderRadius: BorderRadius.circular(6),
        ),
        child: Center(
          child: Text(
            '$day',
            style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
          ),
        ),
      ),
    );
  }
}

/// This is just an example of what might appear inside the overlay.
class CalendarOverlayContent extends StatelessWidget {
  final int day;
  const CalendarOverlayContent({required this.day, Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.grey.shade200,
        borderRadius: BorderRadius.circular(6),
        border: Border.all(color: Colors.grey.shade400),
      ),
      padding: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            'January $day',
            style: const TextStyle(
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 8),
          const Text('2 events scheduled'),
          const SizedBox(height: 8),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
            decoration: BoxDecoration(
              color: Colors.grey.shade300,
              borderRadius: BorderRadius.circular(6),
            ),
            child: const Text('Team Meeting'),
          ),
        ],
      ),
    );
  }
}

Behind the Scenes of a Tap

  1. Tap the cell.
  2. context.findRenderObject() + localToGlobal gives the absolute position.
  3. We create a Rect describing the cell’s screen bounds.
  4. AnchoredOverlayService().showOverlay(...) inserts our AnchoredOverlayWidget at the top of the app (through Overlay.of(context).insert()).

Other Use Cases

  1. Feature Tours: Programmatically move the cutout from one widget to another in steps.
  2. Contextual Menus: Show a quick menu near a button or icon.
  3. Detail Previews: Show expanded info for items in a list without a full page navigation.

Performance, Testing & Tips

  • Performance:

  • Keep overlay animations lightweight; for more complex transitions, consider AnimationController.

  • If you are frequently displaying large or CPU-heavy overlay content, test on lower-end devices.

  • Testing:

  • Use debugPaintSizeEnabled = true in main() to visualize widget boundaries.

  • Verify behavior in portrait/landscape, and on multiple screen sizes.

  • If the anchor widget is removed from the widget tree while an overlay is open, call hideOverlay() to avoid orphan overlays.

  • Accessibility:

  • Ensure that the overlay contents are accessible (e.g., semantically described for screen readers).

  • Consider adding an accessible “Close” or “Dismiss” button inside the overlay for better UX.