Skip to main content
On this pageOverview

Popover

Overview

An anchored floating panel with natural Tab navigation. Unlike Dialog (which is modal and traps focus) or Menu (which uses aria-activedescendant for item navigation), Popover holds arbitrary content and uses the disclosure ARIA pattern. Focus flows naturally through the panel content.

For programmatic control in update functions, use Popover.open(model) and Popover.close(model) which return [Model, Commands] directly.

See it in an app

Check out how Popover is wired up in a real Foldkit app.

Examples

Basic

Pass anchor to position the panel relative to the button. The panel can hold any content — links, forms, or informational text.

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, div, h3, p, span } from './html'

// Add a field to your Model for the Popover Submodel:
const Model = S.Struct({
  popover: Ui.Popover.Model,
  // ...your other fields
})

// In your init function, initialize the Popover Submodel with a unique id:
const init = () => [
  {
    popover: Ui.Popover.init({ id: 'info' }),
    // ...your other fields
  },
  [],
]

// Embed the Popover Message in your parent Message:
const GotPopoverMessage = m('GotPopoverMessage', {
  message: Ui.Popover.Message,
})

// Inside your update function's M.tagsExhaustive({...}), delegate to Popover.update:
GotPopoverMessage: ({ message }) => {
  const [nextPopover, commands] = Ui.Popover.update(model.popover, message)

  return [
    // Merge the next state into your Model:
    evo(model, { popover: () => nextPopover }),
    // Forward the Submodel's Commands through your parent Message:
    commands.map(
      Command.mapEffect(Effect.map(message => GotPopoverMessage({ message }))),
    ),
  ]
}

// Inside your view function, render the popover:
Ui.Popover.view({
  model: model.popover,
  toParentMessage: message => GotPopoverMessage({ message }),
  buttonContent: span([], ['Solutions']),
  buttonClassName: 'rounded-lg border px-3 py-2 cursor-pointer',
  panelContent: div(
    [],
    [
      h3([Class('font-medium')], ['Analytics']),
      p(
        [Class('text-sm text-gray-500')],
        ['Get a better understanding of where your traffic is coming from.'],
      ),
    ],
  ),
  panelClassName: 'rounded-lg border shadow-lg p-4 w-80',
  backdropClassName: 'fixed inset-0',
  anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
})

Animated

Pass isAnimated: true at init for CSS transition coordination.

Styling

Popover is headless — button and panel styling is controlled through className and attribute props.

AttributeCondition
data-openPresent on button and panel when open.
data-disabledPresent on the button when disabled.
data-closedPresent during close animation.

Keyboard Interaction

The panel receives tabindex="0" so it can receive focus. Tab navigates naturally through the panel content. Escape closes and returns focus to the button.

KeyDescription
Enter / SpaceToggles the popover.
EscapeCloses the popover and returns focus to the button.
TabNavigates within the panel. Closes the popover when focus leaves.

Accessibility

The button receives aria-expanded and aria-controls linking to the panel. The panel has no role — Popover uses the disclosure pattern, not the menu pattern.

API Reference

InitConfig

Configuration object passed to Popover.init().

NameTypeDefaultDescription
idstring-Unique ID for the popover instance.
isAnimatedbooleanfalseEnables CSS transition coordination.
isModalbooleanfalseLocks page scroll and marks other elements inert when open.

ViewConfig

Configuration object passed to Popover.view().

NameTypeDefaultDescription
modelPopover.Model-The popover state from your parent Model.
toParentMessage(childMessage: Popover.Message) => ParentMessage-Wraps Popover Messages in your parent Message type for Submodel delegation.
buttonContentHtml-Content rendered inside the trigger button.
panelContentHtml-Content rendered inside the floating panel.
anchorAnchorConfig-Floating positioning config: placement, gap, and padding. Required.
buttonClassNamestring-CSS class for the trigger button.
buttonAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the trigger button.
panelClassNamestring-CSS class for the floating panel.
panelAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the panel.
backdropClassNamestring-CSS class for the backdrop.
isDisabledbooleanfalseDisables the trigger button.
onOpened() => Message-Optional callback fired when the popover opens.
onClosed() => Message-Optional callback fired when the popover closes.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson