image

16 Dec 2025

9K

35K

Creating Interactive Tooltip Widgets in Flutter

Tooltips are an essential UI element for providing contextual information or guidance to users without cluttering the main interface. While Flutter offers a built-in Tooltip widget, creating truly interactive and customizable tooltips often requires a deeper dive into Flutter's rendering mechanisms. This article will guide you through building a professional, interactive tooltip widget in Flutter, complete with custom content, positioning, and dismissible actions.

The Importance of Tooltips in UX

Tooltips serve a vital role in enhancing user experience:

  • Clarity: They clarify the function of ambiguous icons or UI elements.
  • Guidance: They can provide brief instructions or hints for new users.
  • Reduced Clutter: Information is revealed only when needed, keeping the UI clean.
  • Accessibility: They can aid users who rely on visual cues for understanding.

While a basic tooltip simply displays text, an interactive tooltip takes this a step further by allowing users to perform actions directly within the tooltip itself, or by offering richer, custom content beyond plain text.

Flutter's Built-in Tooltip Widget

Flutter provides a straightforward Tooltip widget out of the box. It's easy to use and covers basic needs.

Basic Usage


import 'package:flutter/material.dart';

class BasicTooltipExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Basic Tooltip')),
      body: Center(
        child: Tooltip(
          message: 'This is an information icon.',
          child: Icon(Icons.info),
        ),
      ),
    );
  }
}

Customizing the Built-in Tooltip

You can customize properties like height, padding, textStyle, decoration, and preferBelow.


import 'package:flutter/material.dart';

class CustomBuiltInTooltipExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Custom Built-in Tooltip')),
      body: Center(
        child: Tooltip(
          message: 'Tap this button to perform an action.',
          height: 40,
          padding: EdgeInsets.all(8.0),
          textStyle: TextStyle(color: Colors.white, fontSize: 16),
          decoration: BoxDecoration(
            color: Colors.blueAccent,
            borderRadius: BorderRadius.circular(8.0),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withOpacity(0.2),
                blurRadius: 6,
                offset: Offset(0, 3),
              ),
            ],
          ),
          preferBelow: false, // Show above if possible
          child: ElevatedButton(
            onPressed: () {},
            child: Text('Hover or Long Press Me'),
          ),
        ),
      ),
    );
  }
}

Limitations of the Built-in Tooltip

While convenient, the default Tooltip has limitations:

  • Content: Primarily for text messages. You cannot easily embed complex widgets like buttons or images directly.
  • Interaction: Lacks direct interaction within the tooltip content itself (e.g., a "Got It" button).
  • Triggering: By default, it appears on long press (mobile) or hover (web/desktop). Custom tap triggers are not straightforward.
  • Positioning: While preferBelow helps, fine-grained control over its exact position relative to the target is limited.

Why Build a Custom Interactive Tooltip?

A custom interactive tooltip solves the limitations mentioned above, enabling you to:

  • Embed Any Widget: Display rich content like images, complex layouts, or multiple interactive elements.
  • Custom Triggers: Show the tooltip on tap, double-tap, or any custom gesture.
  • Dynamic Actions: Include buttons or other interactive elements that trigger actions within your app.
  • Precise Positioning: Control the exact placement of the tooltip relative to its target.
  • Advanced Animations: Implement custom show/hide animations for a polished user experience.
  • User Onboarding: Create guided tours with actionable "Next" or "Got It" buttons.

Building a Custom Interactive Tooltip

To create a custom interactive tooltip, we'll leverage Flutter's Overlay and OverlayEntry widgets. An Overlay allows you to layer widgets on top of the existing widget tree, independent of the current widget's position in the tree. This is perfect for displaying transient UI elements like tooltips.

Core Concepts

  • Overlay: A widget that hosts an overlay. Typically, your MaterialApp or WidgetsApp will provide one.
  • OverlayEntry: An entry in an Overlay that can display arbitrary content. We'll use this to build our tooltip.
  • GlobalKey: Used to get the `RenderBox` of the target widget, which is essential for calculating its position and size.
  • GestureDetector: To handle custom tap gestures on the target widget to show/hide the tooltip.
  • Positioned: To precisely place the OverlayEntry content within the Overlay.

Step-by-Step Implementation

Let's create a reusable InteractiveTooltipHost widget that will wrap our target widget and manage the lifecycle of our custom tooltip.


import 'package:flutter/material.dart';

class InteractiveTooltipHost extends StatefulWidget {
  final Widget child; // The widget that triggers the tooltip
  final Widget tooltipContent; // The custom content of the tooltip
  final Duration showDuration; // How long the tooltip stays visible
  final Duration animationDuration; // Duration of show/hide animation
  final bool dismissible; // Whether tapping outside dismisses it
  final TooltipDirection direction; // Direction relative to the child

  const InteractiveTooltipHost({
    Key? key,
    required this.child,
    required this.tooltipContent,
    this.showDuration = const Duration(seconds: 4),
    this.animationDuration = const Duration(milliseconds: 250),
    this.dismissible = true,
    this.direction = TooltipDirection.bottom,
  }) : super(key: key);

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

enum TooltipDirection { top, bottom, left, right }

class _InteractiveTooltipHostState extends State
    with SingleTickerProviderStateMixin {
  OverlayEntry? _overlayEntry;
  final GlobalKey _key = GlobalKey();
  bool _isShowing = false;

  late AnimationController _animationController;
  late Animation _opacityAnimation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: widget.animationDuration,
    );
    _opacityAnimation =
        Tween(begin: 0.0, end: 1.0).animate(_animationController);
  }

  @override
  void dispose() {
    _hideTooltip(force: true); // Ensure tooltip is removed on dispose
    _animationController.dispose();
    super.dispose();
  }

  void _showTooltip() {
    if (_isShowing) return;

    _isShowing = true;

    final RenderBox renderBox = _key.currentContext!.findRenderObject() as RenderBox;
    final Offset offset = renderBox.localToGlobal(Offset.zero);
    final Size size = renderBox.size;
    final Size screenSize = MediaQuery.of(context).size;

    _overlayEntry = OverlayEntry(
      builder: (context) {
        // Calculate tooltip position
        Offset tooltipPosition = _calculateTooltipPosition(
          offset, size, screenSize, widget.direction
        );

        return GestureDetector(
          onTap: widget.dismissible ? _hideTooltip : null,
          child: Material(
            color: Colors.transparent, // Make background tappable but invisible
            child: Stack(
              children: [
                Positioned(
                  left: tooltipPosition.dx,
                  top: tooltipPosition.dy,
                  child: FadeTransition(
                    opacity: _opacityAnimation,
                    child: Material(
                      elevation: 8.0,
                      borderRadius: BorderRadius.circular(8.0),
                      color: Colors.white, // Default tooltip background
                      child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: DefaultTextStyle(
                          style: const TextStyle(color: Colors.black, fontSize: 14),
                          child: widget.tooltipContent,
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );

    Overlay.of(context)?.insert(_overlayEntry!);
    _animationController.forward().then((_) {
      // Auto-dismiss after showDuration if not dismissed manually
      Future.delayed(widget.showDuration, () {
        if (_isShowing && mounted) {
          _hideTooltip();
        }
      });
    });
  }

  void _hideTooltip({bool force = false}) {
    if (!_isShowing && !force) return; // Already hidden or not showing
    _isShowing = false;

    _animationController.reverse().then((_) {
      _overlayEntry?.remove();
      _overlayEntry = null;
    });
  }

  Offset _calculateTooltipPosition(
    Offset targetOffset, Size targetSize, Size screenSize, TooltipDirection direction
  ) {
    double x = targetOffset.dx;
    double y = targetOffset.dy;
    double tooltipWidth = 200; // Assume a default width, or measure tooltip content
    double tooltipHeight = 100; // Assume a default height, or measure tooltip content

    // Note: For production, you'd want to calculate actual tooltipContent size
    // For simplicity, we'll use assumed sizes or estimate based on typical content.
    // A more robust solution involves using an OverlayEntry that measures its child
    // before repositioning, or pre-calculating max size.

    switch (direction) {
      case TooltipDirection.bottom:
        x = targetOffset.dx + (targetSize.width / 2) - (tooltipWidth / 2);
        y = targetOffset.dy + targetSize.height + 10; // 10px offset
        if (x < 0) x = 0;
        if (x + tooltipWidth > screenSize.width) x = screenSize.width - tooltipWidth;
        break;
      case TooltipDirection.top:
        x = targetOffset.dx + (targetSize.width / 2) - (tooltipWidth / 2);
        y = targetOffset.dy - tooltipHeight - 10;
        if (x < 0) x = 0;
        if (x + tooltipWidth > screenSize.width) x = screenSize.width - tooltipWidth;
        break;
      case TooltipDirection.left:
        x = targetOffset.dx - tooltipWidth - 10;
        y = targetOffset.dy + (targetSize.height / 2) - (tooltipHeight / 2);
        if (y < 0) y = 0;
        if (y + tooltipHeight > screenSize.height) y = screenSize.height - tooltipHeight;
        break;
      case TooltipDirection.right:
        x = targetOffset.dx + targetSize.width + 10;
        y = targetOffset.dy + (targetSize.height / 2) - (tooltipHeight / 2);
        if (y < 0) y = 0;
        if (y + tooltipHeight > screenSize.height) y = screenSize.height - tooltipHeight;
        break;
    }

    // Ensure tooltip doesn't go off-screen horizontally
    x = x.clamp(0.0, screenSize.width - tooltipWidth);
    // Ensure tooltip doesn't go off-screen vertically (consider AppBar, etc.)
    y = y.clamp(0.0, screenSize.height - tooltipHeight);

    return Offset(x, y);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      key: _key,
      onTap: () {
        if (_isShowing) {
          _hideTooltip();
        } else {
          _showTooltip();
        }
      },
      child: widget.child,
    );
  }
}

Using the InteractiveTooltipHost

Now, let's see how to integrate this custom tooltip host into your application, providing interactive content.


import 'package:flutter/material.dart';
// Make sure to import your InteractiveTooltipHost file here
// import 'interactive_tooltip_host.dart'; // Assuming it's in a separate file

class MyInteractiveTooltipApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Interactive Tooltip Demo')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              InteractiveTooltipHost(
                direction: TooltipDirection.bottom,
                tooltipContent: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text('This is a custom message!'),
                    SizedBox(height: 8),
                    ElevatedButton(
                      onPressed: () {
                        // Perform an action
                        print('Action button tapped!');
                        // You might want to dismiss the tooltip here as well
                        // Navigator.of(context).pop(); // Or find a way to dismiss the specific OverlayEntry
                      },
                      child: Text('Take Action'),
                    ),
                    SizedBox(height: 4),
                    TextButton(
                      onPressed: () {
                        // You could trigger an internal hide method here
                        // For a real app, you'd pass a callback to dismiss the tooltip
                        print('Dismiss tapped!');
                      },
                      child: Text('Dismiss'),
                    ),
                  ],
                ),
                child: Container(
                  padding: EdgeInsets.all(12),
                  decoration: BoxDecoration(
                    color: Colors.purple,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Text(
                    'Tap me for info',
                    style: TextStyle(color: Colors.white, fontSize: 18),
                  ),
                ),
              ),
              SizedBox(height: 50),
              InteractiveTooltipHost(
                direction: TooltipDirection.top,
                dismissible: false, // User must tap "Got It!"
                showDuration: Duration(minutes: 5), // Stays until "Got It!" is pressed
                tooltipContent: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      'Welcome to this feature! Tap "Got It!" to continue.',
                      textAlign: TextAlign.center,
                    ),
                    SizedBox(height: 8),
                    ElevatedButton(
                      onPressed: () {
                        // In a real scenario, you'd use a GlobalKey or a callback
                        // to get a reference to the _InteractiveTooltipHostState
                        // and call _hideTooltip()
                        ScaffoldMessenger.of(context).showSnackBar(
                          SnackBar(content: Text('Tooltip dismissed via button!')),
                        );
                        // For demonstration, we'll manually refresh, but you need a proper state management
                        // to dismiss this specific tooltip.
                        // A more elegant solution would involve passing a dismiss callback
                        // from _InteractiveTooltipHostState to tooltipContent.
                      },
                      child: Text('Got It!'),
                    ),
                  ],
                ),
                child: FloatingActionButton(
                  onPressed: () {},
                  child: Icon(Icons.add),
                  heroTag: 'add_button_tooltip',
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

void main() {
  runApp(MyInteractiveTooltipApp());
}

Enhancements and Considerations

  • Dynamic Tooltip Size: The current position calculation uses assumed tooltipWidth and tooltipHeight. For a robust solution, you'd need to calculate the actual size of the tooltipContent after it's rendered in the OverlayEntry, possibly by using a LayoutBuilder or a custom RenderBox that reports its size.
  • Arrow Pointer: Add an arrow to the tooltip content that points to the target widget for better visual clarity. This would involve drawing a custom shape (e.g., using CustomPainter) based on the tooltip's position relative to the target.
  • Auto-Positioning: Implement logic to automatically choose the best tooltip direction (top, bottom, left, right) based on available screen space.
  • Dismiss Callback: Pass a VoidCallback from _InteractiveTooltipHostState to tooltipContent so that buttons inside the tooltip can explicitly dismiss it.
  • Accessibility: Ensure tooltips are accessible, providing equivalent information for screen readers.

Conclusion

Creating interactive tooltip widgets in Flutter, while requiring a deeper understanding of the Overlay system, offers unparalleled flexibility and control. By leveraging OverlayEntry, GlobalKey, and custom gestures, you can build sophisticated tooltips that enhance user onboarding, provide actionable information, and significantly improve the overall user experience of your Flutter applications. Experiment with different animations, content types, and interaction patterns to create truly engaging UI elements.

Related Articles

Dec 18, 2025

Flutter &amp; Firebase Realtime Database: Data

Flutter &amp; Firebase Realtime Database: Data Synchronization In the realm of modern application development, providing users with up-to-date and consistent d

Dec 18, 2025

Building an Expandable FAQ Widget in Flutter

Building an Expandable FAQ Widget in Flutter Frequently Asked Questions (FAQ) sections are a common and essential component of many applications and websi

Dec 18, 2025

Flutter State Management with GetX Reactive

Flutter State Management with GetX Reactive Flutter's declarative UI paradigm simplifies application development, but managing application state effectively re