NakedPopover

Headless anchored popover with focus management, outside-tap dismissal, and lifecycle hooks

Headless popover component. Handles positioning, focus management, outside dismissal, and lifecycle. Use builder pattern for custom styling.

When to use this

  • Additional information: Show details or help text on demand
  • Form fields: Display validation errors or input help
  • Action confirmations: "Are you sure?" confirmations for buttons
  • Content previews: Preview links, images, or documents

Basic implementation

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

class PopoverExample extends StatelessWidget {
  const PopoverExample({super.key});

  @override
  Widget build(BuildContext context) {
    return NakedPopover(
      positioning: const OverlayPositionConfig(
        alignment: Alignment.bottomCenter,
        fallbackAlignment: Alignment.topCenter,
      ),
      popoverBuilder: (context, info) {
        return Material(
          elevation: 6,
          borderRadius: BorderRadius.circular(12),
          child: SizedBox(
            width: 220,
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Invite teammates',
                      style: TextStyle(fontWeight: FontWeight.w600)),
                  const SizedBox(height: 8),
                  const Text('Share this project link to collaborate.'),
                  const SizedBox(height: 12),
                  ElevatedButton(
                    onPressed: () => Navigator.of(context).maybePop(),
                    child: const Text('Copy link'),
                  ),
                ],
              ),
            ),
          ),
        );
      },
      builder: (context, state, _) {
        final bool pressed = state.isPressed;
        return Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
          decoration: BoxDecoration(
            color: pressed ? Colors.blue.shade700 : Colors.blue.shade600,
            borderRadius: BorderRadius.circular(8),
          ),
          child: const Text('Open popover', style: TextStyle(color: Colors.white)),
        );
      },
    );
  }
}

Animated Popover with Lifecycle Hooks

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

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

  @override
  State<AnimatedPopover> createState() => _AnimatedPopoverState();
}

class _AnimatedPopoverState extends State<AnimatedPopover>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    duration: const Duration(milliseconds: 200),
    vsync: this,
  );

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

  @override
  Widget build(BuildContext context) {
    return NakedPopover(
      positioning: const OverlayPositionConfig(),
      onOpen: () => _controller.forward(),
      onClose: () => _controller.reverse(),
      popoverBuilder: (context, info) {
        return FadeTransition(
          opacity: _controller,
          child: ScaleTransition(
            scale: Tween<double>(begin: 0.9, end: 1.0).animate(
              CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
            ),
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.black,
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Padding(
                padding: EdgeInsets.all(16),
                child: const Text(
                  'Animated popover content',
                  style: TextStyle(color: Colors.white),
                ),
              ),
            ),
          ),
        );
      },
      child: const Icon(Icons.info_outline, color: Colors.black87),
    );
  }
}

Constructor

const NakedPopover({
  Key? key,
  this.child,
  this.builder,
  required this.popoverBuilder,
  this.positioning = const OverlayPositionConfig(),
  this.consumeOutsideTaps = true,
  this.useRootOverlay = false,
  this.openOnTap = true,
  this.triggerFocusNode,
  this.onOpen,
  this.onClose,
  this.onOpenRequested,
  this.onCloseRequested,
  this.controller,
})

Key Parameters

  • child / builder → provide a static trigger or a ValueWidgetBuilder<NakedPopoverState> for dynamic styling
  • popoverBuilder(BuildContext, RawMenuAnchorOverlayInfo) to build the overlay content
  • positioningOverlayPositionConfig controlling alignment/offset/fallbacks
  • openOnTap → disable to manage open state manually (e.g. hover triggers)
  • consumeOutsideTaps / useRootOverlay → control gesture capture and overlay target
  • triggerFocusNode → provide an external FocusNode for restoring focus after close
  • Lifecycle hooks: onOpen, onClose, onOpenRequested, onCloseRequested

Behaviour & Accessibility

  • Tap trigger or press Space/Enter to open when openOnTap is true
  • Escape closes and returns focus to the trigger
  • Outside taps close automatically (configurable via consumeOutsideTaps)
  • Wrap complex content in FocusTraversalGroup or provide your own focusable widgets inside the popover for keyboard navigation
  • Provide a semanticLabel inside the popover content as needed (e.g. heading)

Positioning Helpers

Use OverlayPositionConfig to adjust anchor placement:

const OverlayPositionConfig(
  targetAnchor: Alignment.bottomCenter,
  followerAnchor: Alignment.topCenter,
  offset: Offset(0, 8),
);

The builder receives RawMenuAnchorOverlayInfo (info in the sample) with anchorRect, overlaySize, and position should you need custom math.