image

12 Feb 2026

9K

35K

Creating Interactive Custom Tooltip Widgets in Flutter

Tooltips are essential UI elements that provide contextual information or hints when a user interacts with a component, typically by hovering over or tapping it. While Flutter provides a built-in Tooltip widget, its customization options are limited, often making it challenging to achieve specific designs or interactive behaviors. This article will guide you through creating a highly customizable and interactive custom tooltip widget in Flutter, leveraging OverlayEntry and advanced positioning techniques.

Why Custom Tooltips?

Flutter's standard Tooltip widget is simple and effective for displaying plain text. However, when your design requires:

  • Rich content within the tooltip (e.g., images, buttons, multiple text styles).
  • Custom shapes, shadows, or background decorations.
  • Complex show/hide animations.
  • Interactive elements inside the tooltip that the user can click.
  • Dynamic positioning logic.

...you'll quickly find the built-in widget insufficient. Building a custom solution gives you complete control over its appearance and behavior.

Core Concepts for Custom Tooltips

To create a custom interactive tooltip, we primarily rely on Flutter's OverlayEntry mechanism and precise positioning:

1. OverlayEntry and OverlayState

The Overlay widget is a stack-like widget that can display widgets on top of others, independently of the widget tree structure. An OverlayEntry is essentially a "portal" through which you can insert any widget into the Overlay. This is perfect for tooltips, which need to appear above all other content.


// To show an OverlayEntry
Overlay.of(context)!.insert(_overlayEntry);

// To hide an OverlayEntry
_overlayEntry?.remove();

2. Positioning with CompositedTransformTarget and CompositedTransformFollower

When creating an overlay, we need to position our custom tooltip relative to its target widget. Directly calculating coordinates can be brittle. Flutter offers a more robust solution:

  • CompositedTransformTarget: Placed around the widget that the tooltip should "follow." It marks a render object as a potential anchor point.
  • CompositedTransformFollower: Placed within the OverlayEntry. It uses the `link` property to connect to a specific CompositedTransformTarget and then positions its child relative to that target. This ensures the tooltip stays correctly positioned even if the target widget moves or the screen scrolls.

3. Detecting Interaction: GestureDetector and MouseRegion

To trigger the tooltip, we'll use:

  • GestureDetector: For tap events on mobile devices.
  • MouseRegion: For hover events on web and desktop, providing `onEnter` and `onExit` callbacks.

4. Animations

For a smooth user experience, tooltips should typically animate their appearance and disappearance. We can use `FadeTransition` or `SlideTransition` with an `AnimationController` for this.

Step-by-Step Implementation

Let's build a reusable InteractiveTooltip widget.

1. The InteractiveTooltip Widget Structure

This widget will manage the lifecycle of the OverlayEntry and handle the show/hide logic.


import 'package:flutter/material.dart';
import 'dart:async';

enum TooltipDirection {
  top,
  bottom,
  left,
  right,
}

class InteractiveTooltip extends StatefulWidget {
  final Widget child;
  final Widget Function(BuildContext context, VoidCallback hideTooltip) tooltipContentBuilder;
  final TooltipDirection direction;
  final Duration showDuration;
  final Duration hideDelay;
  final Duration animationDuration;
  final bool showOnTap;
  final bool showOnHover;

  const InteractiveTooltip({
    Key? key,
    required this.child,
    required this.tooltipContentBuilder,
    this.direction = TooltipDirection.bottom,
    this.showDuration = const Duration(seconds: 5),
    this.hideDelay = const Duration(milliseconds: 100),
    this.animationDuration = const Duration(milliseconds: 200),
    this.showOnTap = false,
    this.showOnHover = true,
  }) : super(key: key);

  @override
  _InteractiveTooltipState createState() => _InteractiveTooltipState();
}

class _InteractiveTooltipState extends State with SingleTickerProviderStateMixin {
  OverlayEntry? _overlayEntry;
  final GlobalKey _targetKey = GlobalKey();
  final LayerLink _layerLink = LayerLink();
  Timer? _hideTimer;
  AnimationController? _animationController;
  Animation? _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: widget.animationDuration,
    );
    _animation = CurvedAnimation(parent: _animationController!, curve: Curves.easeOut);
  }

  @override
  void dispose() {
    _hideTimer?.cancel();
    _overlayEntry?.remove();
    _animationController?.dispose();
    super.dispose();
  }

  void _showTooltip() {
    if (_overlayEntry != null) return; // Tooltip is already showing

    _overlayEntry = OverlayEntry(
      builder: (context) {
        return FadeTransition(
          opacity: _animation!,
          child: _TooltipOverlay(
            targetKey: _targetKey,
            layerLink: _layerLink,
            direction: widget.direction,
            hideTooltip: _hideTooltip,
            tooltipContentBuilder: widget.tooltipContentBuilder,
            animationDuration: widget.animationDuration,
          ),
        );
      },
    );

    Overlay.of(context)!.insert(_overlayEntry!);
    _animationController?.forward();

    if (widget.showDuration != Duration.zero) {
      _hideTimer = Timer(widget.showDuration, _hideTooltip);
    }
  }

  void _hideTooltip() {
    _hideTimer?.cancel(); // Cancel any pending hide timers
    if (_overlayEntry == null) return; // Tooltip is not showing

    _animationController?.reverse().then((_) {
      if (mounted) { // Ensure the widget is still mounted before removing
        _overlayEntry?.remove();
        _overlayEntry = null;
      }
    });
  }

  void _handleTap() {
    if (widget.showOnTap) {
      _hideTimer?.cancel(); // Cancel hide if tapped to show
      if (_overlayEntry == null) {
        _showTooltip();
      } else {
        _hideTooltip();
      }
    }
  }

  void _handleHoverEnter() {
    if (widget.showOnHover) {
      _hideTimer?.cancel(); // Cancel hide if hovering
      _showTooltip();
    }
  }

  void _handleHoverExit() {
    if (widget.showOnHover) {
      _hideTimer = Timer(widget.hideDelay, _hideTooltip);
    }
  }

  @override
  Widget build(BuildContext context) {
    Widget child = CompositedTransformTarget(
      link: _layerLink,
      key: _targetKey,
      child: widget.child,
    );

    if (widget.showOnTap) {
      child = GestureDetector(
        onTap: _handleTap,
        child: child,
      );
    }

    if (widget.showOnHover) {
      child = MouseRegion(
        onEnter: (_) => _handleHoverEnter(),
        onExit: (_) => _handleHoverExit(),
        child: child,
      );
    }

    return child;
  }
}

2. The _TooltipOverlay Widget for Content and Positioning

This widget will be placed inside the OverlayEntry and uses CompositedTransformFollower for correct positioning.


class _TooltipOverlay extends StatelessWidget {
  final GlobalKey targetKey;
  final LayerLink layerLink;
  final TooltipDirection direction;
  final VoidCallback hideTooltip;
  final Widget Function(BuildContext context, VoidCallback hideTooltip) tooltipContentBuilder;
  final Duration animationDuration;

  const _TooltipOverlay({
    Key? key,
    required this.targetKey,
    required this.layerLink,
    required this.direction,
    required this.hideTooltip,
    required this.tooltipContentBuilder,
    required this.animationDuration,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final RenderBox renderBox = targetKey.currentContext!.findRenderObject() as RenderBox;
    final Size targetSize = renderBox.size;
    const double tooltipPadding = 8.0; // Space between target and tooltip

    Offset offset;
    Alignment alignment;

    switch (direction) {
      case TooltipDirection.top:
        offset = Offset(targetSize.width / 2, -tooltipPadding);
        alignment = Alignment.bottomCenter;
        break;
      case TooltipDirection.bottom:
        offset = Offset(targetSize.width / 2, targetSize.height + tooltipPadding);
        alignment = Alignment.topCenter;
        break;
      case TooltipDirection.left:
        offset = Offset(-tooltipPadding, targetSize.height / 2);
        alignment = Alignment.centerRight;
        break;
      case TooltipDirection.right:
        offset = Offset(targetSize.width + tooltipPadding, targetSize.height / 2);
        alignment = Alignment.centerLeft;
        break;
    }

    return Positioned(
      width: MediaQuery.of(context).size.width, // Spanning full width to allow flexible positioning
      height: MediaQuery.of(context).size.height, // Spanning full height
      child: CompositedTransformFollower(
        link: layerLink,
        offset: offset,
        followerAnchor: alignment,
        targetAnchor: alignment, // Match follower anchor for simple positioning
        child: Align(
          alignment: alignment, // Align the tooltip content within the follower's space
          child: Material( // Wrap with Material to give elevation/shadow
            color: Colors.transparent, // Make Material transparent
            child: MouseRegion( // Allow interaction with the tooltip content
              onExit: (_) => Timer(animationDuration, hideTooltip), // Hide after a slight delay
              child: widget.tooltipContentBuilder(context, hideTooltip),
            ),
          ),
        ),
      ),
    );
  }
}

3. Example Usage in main.dart

Here's how you can integrate the InteractiveTooltip into your application:


import 'package:flutter/material.dart';
// Make sure to import the file where InteractiveTooltip is defined
// For example: import 'interactive_tooltip.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Custom Interactive Tooltip Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Interactive Tooltip Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            InteractiveTooltip(
              direction: TooltipDirection.bottom,
              showOnTap: true,
              showOnHover: false, // Only show on tap for this one
              showDuration: const Duration(seconds: 10), // Stay for 10 seconds or until dismissed
              tooltipContentBuilder: (context, hideTooltip) => Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.blueAccent,
                  borderRadius: BorderRadius.circular(8),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.2),
                      blurRadius: 4,
                      offset: const Offset(0, 2),
                    ),
                  ],
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text(
                      'This is a custom interactive tooltip!',
                      style: TextStyle(color: Colors.white, fontSize: 16),
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 8),
                    ElevatedButton(
                      onPressed: () {
                        // Perform an action, then hide the tooltip
                        ScaffoldMessenger.of(context).showSnackBar(
                          const SnackBar(content: Text('Action taken!')),
                        );
                        hideTooltip();
                      },
                      style: ElevatedButton.styleFrom(
                        backgroundColor: Colors.white,
                        foregroundColor: Colors.blueAccent,
                      ),
                      child: const Text('Take Action'),
                    ),
                  ],
                ),
              ),
              child: ElevatedButton(
                onPressed: () {}, // Empty onPressed as tap handled by tooltip
                child: const Text('Tap Me for Info'),
              ),
            ),
            const SizedBox(height: 50),
            InteractiveTooltip(
              direction: TooltipDirection.right,
              showOnTap: false,
              showOnHover: true, // Only show on hover for this one
              hideDelay: const Duration(milliseconds: 300), // Slightly longer delay to allow cursor to enter tooltip
              showDuration: Duration.zero, // Keep showing as long as hovered
              tooltipContentBuilder: (context, hideTooltip) => Card(
                elevation: 4,
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      const Icon(Icons.info_outline, color: Colors.indigo, size: 30),
                      const SizedBox(height: 10),
                      const Text(
                        'Hover tooltip with icon!',
                        style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 5),
                      Text(
                        'You can customize this content extensively.',
                        style: TextStyle(fontSize: 12, color: Colors.grey[600]),
                        textAlign: TextAlign.center,
                      ),
                    ],
                  ),
                ),
              ),
              child: Container(
                padding: const EdgeInsets.all(10),
                decoration: BoxDecoration(
                  color: Colors.indigo[100],
                  borderRadius: BorderRadius.circular(5),
                ),
                child: const Text(
                  'Hover over me',
                  style: TextStyle(color: Colors.indigo),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Explanation and Customization Points

  • InteractiveTooltip State Management: The `_InteractiveTooltipState` manages the `OverlayEntry` and an `AnimationController` for fade effects. It uses a `GlobalKey` for the target widget and a `LayerLink` to connect the `CompositedTransformTarget` and `CompositedTransformFollower`.
  • `tooltipContentBuilder` Callback: This powerful callback allows you to define the exact content of your tooltip. It receives the `BuildContext` and a `hideTooltip` function, which is crucial for making the content itself interactive (e.g., a button inside the tooltip that dismisses it).
  • Direction and Positioning: The `TooltipDirection` enum and logic within `_TooltipOverlay` allow you to specify where the tooltip should appear relative to its child. The `offset` and `alignment` properties of `CompositedTransformFollower` are key here.
  • Show/Hide Logic:
    • `showDuration`: How long the tooltip remains visible after appearing (set to `Duration.zero` to keep it open indefinitely until dismissed).
    • `hideDelay`: A short delay before hiding on hover exit, allowing the cursor to move into the tooltip content without immediately dismissing it.
    • `showOnTap` / `showOnHover`: Booleans to control activation methods.
    • `_hideTimer`: Used to automatically dismiss the tooltip after `showDuration`.
  • Interactivity: The `MouseRegion` around the `tooltipContentBuilder` in `_TooltipOverlay` ensures that when the mouse hovers over the tooltip itself, it doesn't immediately trigger `onExit` from the target, preventing rapid flickering.

Conclusion

By leveraging Flutter's OverlayEntry, CompositedTransformTarget, and CompositedTransformFollower, you can build highly customizable and interactive tooltip widgets that go far beyond the capabilities of the default Tooltip. This approach gives you granular control over the tooltip's content, styling, positioning, and animation, enabling you to create rich and engaging user interfaces tailored to your application's specific needs.

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