← Back to Articles

Flutter Overlays: Creating Floating UI Elements with OverlayEntry

Flutter Overlays: Creating Floating UI Elements with OverlayEntry

Flutter Overlays: Creating Floating UI Elements with OverlayEntry

Have you ever wanted to create a custom tooltip that appears above other widgets, or build a floating menu that doesn't disrupt your widget tree? Flutter's Overlay system is your answer. While many developers use standard dialogs and tooltips, understanding Overlays opens up a world of possibilities for creating sophisticated floating UI elements.

In this article, we'll explore what Overlays are, how they work, and when to use them. By the end, you'll be able to create custom floating widgets that appear anywhere on your screen, independent of your main widget tree.

What Are Overlays?

An Overlay is a special widget that sits above all other widgets in your app. Think of it as a transparent layer that can hold multiple floating widgets called OverlayEntries. These entries can be positioned anywhere on the screen and are perfect for elements like tooltips, dropdown menus, custom dialogs, or any UI that needs to float above your main content.

The key advantage of Overlays is that they exist outside your normal widget hierarchy. This means you can show a floating widget without restructuring your entire widget tree or worrying about z-index issues.

Overlay Structure Diagram App Screen Main Widget Main Widget Main Widget Overlay Entry Floating

Understanding OverlayEntry

An OverlayEntry is a single widget that lives inside an Overlay. Each entry has a builder function that returns the widget to display. The magic happens when you insert or remove these entries dynamically.

Here's a basic example of creating and showing an OverlayEntry:


import 'package:flutter/material.dart';

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

  @override
  State<OverlayExample> createState() => _OverlayExampleState();
}

class _OverlayExampleState extends State<OverlayExample> {
  OverlayEntry? _overlayEntry;

  void _showOverlay() {
    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        top: 100,
        left: 100,
        child: Material(
          color: Colors.transparent,
          child: Container(
            width: 200,
            height: 100,
            decoration: BoxDecoration(
              color: Colors.blue,
              borderRadius: BorderRadius.circular(8),
            ),
            child: const Center(
              child: Text(
                'Floating Widget!',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Overlay Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: _showOverlay,
              child: const Text('Show Overlay'),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _hideOverlay,
              child: const Text('Hide Overlay'),
            ),
          ],
        ),
      ),
    );
  }
}

In this example, we create an OverlayEntry with a builder that returns a Positioned widget. The Positioned widget allows us to place the overlay anywhere on the screen using top, left, right, or bottom properties. When we call Overlay.of(context).insert(), the overlay appears above all other widgets.

Positioning Overlays Relative to Widgets

One of the most common use cases is positioning an overlay relative to another widget, like showing a tooltip above a button. To do this, we need to find the position of the target widget using a GlobalKey and RenderBox.


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

  @override
  State<TooltipOverlayExample> createState() => _TooltipOverlayExampleState();
}

class _TooltipOverlayExampleState extends State<TooltipOverlayExample> {
  final GlobalKey _buttonKey = GlobalKey();
  OverlayEntry? _overlayEntry;

  void _showTooltip() {
    final RenderBox? renderBox = 
        _buttonKey.currentContext?.findRenderObject() as RenderBox?;
    
    if (renderBox == null) return;

    final Offset offset = renderBox.localToGlobal(Offset.zero);
    final Size size = renderBox.size;

    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        left: offset.dx,
        top: offset.dy - 50,
        width: size.width,
        child: Material(
          color: Colors.transparent,
          child: Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.grey[800],
              borderRadius: BorderRadius.circular(4),
            ),
            child: const Text(
              'This is a custom tooltip!',
              style: TextStyle(color: Colors.white, fontSize: 12),
            ),
          ),
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideTooltip() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tooltip Overlay')),
      body: Center(
        child: ElevatedButton(
          key: _buttonKey,
          onPressed: _showTooltip,
          onLongPress: _hideTooltip,
          child: const Text('Hover for Tooltip'),
        ),
      ),
    );
  }
}

This example demonstrates how to position an overlay relative to a button. We use a GlobalKey to get a reference to the button's RenderBox, which gives us its position and size. Then we calculate where to place the tooltip above the button.

Overlay Positioning Diagram Positioning Overlay Relative to Widget Button Widget Overlay Tooltip offset.dy - 50 offset.dx

Creating a Reusable Overlay Helper

To make overlays easier to work with, let's create a reusable helper class that handles the complexity of showing and hiding overlays:


class OverlayHelper {
  static OverlayEntry? _currentOverlay;

  static void showOverlay(
    BuildContext context, {
    required Widget child,
    Offset? position,
    Alignment alignment = Alignment.center,
  }) {
    hideOverlay();

    _currentOverlay = OverlayEntry(
      builder: (context) => Stack(
        children: [
          Positioned.fill(
            child: GestureDetector(
              onTap: hideOverlay,
              child: Container(color: Colors.transparent),
            ),
          ),
          if (position != null)
            Positioned(
              left: position.dx,
              top: position.dy,
              child: child,
            )
          else
            Align(
              alignment: alignment,
              child: child,
            ),
        ],
      ),
    );

    Overlay.of(context).insert(_currentOverlay!);
  }

  static void hideOverlay() {
    _currentOverlay?.remove();
    _currentOverlay = null;
  }
}

This helper class provides a clean API for showing overlays. It automatically handles cleanup and provides options for positioning. The Stack with a transparent GestureDetector allows users to tap outside the overlay to dismiss it, which is a common UX pattern.

Advanced Example: Custom Dropdown Menu

Let's build a more complex example: a custom dropdown menu that appears when you tap a button. This demonstrates how overlays can be used for interactive UI elements:


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

  @override
  State<CustomDropdownExample> createState() => _CustomDropdownExampleState();
}

class _CustomDropdownExampleState extends State<CustomDropdownExample> {
  final GlobalKey _buttonKey = GlobalKey();
  OverlayEntry? _overlayEntry;
  String _selectedItem = 'Select an option';

  void _showDropdown() {
    if (_overlayEntry != null) {
      _hideDropdown();
      return;
    }

    final RenderBox? renderBox = 
        _buttonKey.currentContext?.findRenderObject() as RenderBox?;
    
    if (renderBox == null) return;

    final Offset offset = renderBox.localToGlobal(Offset.zero);
    final Size size = renderBox.size;

    _overlayEntry = OverlayEntry(
      builder: (context) => Stack(
        children: [
          Positioned.fill(
            child: GestureDetector(
              onTap: _hideDropdown,
              child: Container(color: Colors.transparent),
            ),
          ),
          Positioned(
            left: offset.dx,
            top: offset.dy + size.height + 4,
            width: size.width,
            child: Material(
              elevation: 4,
              borderRadius: BorderRadius.circular(8),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  _buildMenuItem('Option 1'),
                  _buildMenuItem('Option 2'),
                  _buildMenuItem('Option 3'),
                  _buildMenuItem('Option 4'),
                ],
              ),
            ),
          ),
        ],
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  Widget _buildMenuItem(String text) {
    return InkWell(
      onTap: () {
        setState(() {
          _selectedItem = text;
        });
        _hideDropdown();
      },
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        child: Row(
          children: [
            Text(text),
            if (_selectedItem == text) ...[
              const Spacer(),
              const Icon(Icons.check, size: 16),
            ],
          ],
        ),
      ),
    );
  }

  void _hideDropdown() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Custom Dropdown')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Selected: $_selectedItem'),
            const SizedBox(height: 20),
            ElevatedButton(
              key: _buttonKey,
              onPressed: _showDropdown,
              child: const Text('Open Dropdown'),
            ),
          ],
        ),
      ),
    );
  }
}

This example shows how overlays can be used to create interactive dropdown menus. The overlay appears below the button, and users can select an option or tap outside to dismiss it.

Custom Dropdown Overlay Diagram Custom Dropdown with Overlay Button Option 1 Option 2 Option 3 Option 4

Important Considerations

When working with overlays, keep these points in mind:

Memory Management

Always remove overlay entries when they're no longer needed. Failing to do so can cause memory leaks. The best practice is to remove overlays in the dispose method of your State class, as shown in the examples above.

Context Availability

Overlays require a valid BuildContext. Make sure you're calling Overlay.of(context) from within a widget tree that has access to an Overlay. Most MaterialApp and CupertinoApp widgets provide this automatically.

Performance

Overlays rebuild when their builder functions are called. If your overlay contains expensive widgets, consider using const constructors or caching to optimize performance.

Accessibility

When creating custom overlays, ensure they're accessible. Use Semantics widgets to provide screen reader support, and make sure keyboard navigation works correctly.

When to Use Overlays

Overlays are perfect for:

  • Custom tooltips that need precise positioning
  • Dropdown menus and popup menus
  • Floating action buttons with menus
  • Custom modal dialogs
  • Toast notifications
  • Context menus
  • Any UI element that needs to float above other widgets

However, if you can achieve your goal with standard Flutter widgets like Dialog, BottomSheet, or Tooltip, prefer those as they handle edge cases and accessibility automatically.

Conclusion

Flutter's Overlay system is a powerful tool for creating floating UI elements that exist outside your normal widget hierarchy. By understanding how OverlayEntry works and how to position overlays relative to other widgets, you can build sophisticated custom UI components.

Remember to always clean up overlay entries to prevent memory leaks, and consider accessibility when building custom overlays. With practice, you'll find overlays to be an invaluable tool for creating polished, professional Flutter applications.

Happy coding, and may your overlays always appear exactly where you want them!