Naked UI

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) {
    final menuController = MenuController();
    return NakedPopover(
      controller: menuController,
      positioning: const OverlayPositionConfig(
        side: OverlaySide.bottom,
        alignment: OverlayAlignment.center,
        sideOffset: 8,
      ),
      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: () => menuController.close(),
                    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({
  super.key,
  this.child,
  this.builder,
  required this.popoverBuilder,
  this.positioning = const OverlayPositionConfig(),
  this.consumeOutsideTaps = true,
  this.useRootOverlay = false,
  this.openOnTap = true,
  this.anchorKey,
  this.triggerFocusNode,
  this.onOpen,
  this.onClose,
  this.onOpenRequested,
  this.onCloseRequested,
  this.controller,
  this.semanticLabel,
  this.excludeSemantics = false,
})

Key Parameters

  • child / builder → provide a static trigger or a ValueWidgetBuilder<NakedPopoverState> for dynamic styling
  • popoverBuilder → (BuildContext, RawMenuOverlayInfo) to build the overlay content
  • positioning → OverlayPositionConfig 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
  • semanticLabel → provide an accessible name for the popover trigger
  • excludeSemantics → hide the popover trigger subtree from the semantic tree; opened popover content remains exposed

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:

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

OverlayPositionConfig examplePosition() => const OverlayPositionConfig(
  side: OverlaySide.bottom,
        alignment: OverlayAlignment.center,
        sideOffset: 8,
      );

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

When openOnTap is false, supply a focusable child and pass that control’s focus node as triggerFocusNode. The popover adds no trigger semantics in this mode, so the child owns its accessible label.

View page source on GitHub