On this pageOverview
Popover
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, Option<OutMessage>] directly.
See it in an app
Check out how Popover is wired up in a real Foldkit app.
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 { Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
// 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
// Ui.Popover.update. The OutMessages `Opened` and `Closed` mark the
// visibility transitions. Fire analytics, coordinate with other UI,
// or clear ephemeral state on close.
GotPopoverMessage: ({ message }) => {
const [nextPopover, commands, maybeOutMessage] = Ui.Popover.update(
model.popover,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotPopoverMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [evo(model, { popover: () => nextPopover }), mappedCommands],
onSome: M.type<Ui.Popover.OutMessage>().pipe(
M.tagsExhaustive({
Opened: () => [
// The child has emitted `Opened`. The body commits the
// child's next state as usual. In this arm the parent can
// also update its own state or dispatch its own Commands,
// for example lazy-load panel content, log analytics, or
// trigger a downstream Command.
evo(model, { popover: () => nextPopover }),
mappedCommands,
],
Closed: () => [
// The child has emitted `Closed`. The body commits the
// child's next state as usual. In this arm the parent can
// also update its own state or dispatch its own Commands,
// for example persist a draft, clear ephemeral state, or
// trigger a downstream Command.
evo(model, { popover: () => nextPopover }),
mappedCommands,
],
}),
),
})
}
// Inside your view function, embed the popover via h.submodel:
const view = () => {
const h = html<Message>()
return h.submodel({
slotId: 'info',
model: model.popover,
view: Ui.Popover.view,
viewInputs: {
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
toView: ({ button, panel, backdrop, isVisible }) =>
h.div(
[h.Class('relative inline-block')],
[
h.button(
[
...button,
h.Class('rounded-lg border px-3 py-2 cursor-pointer'),
],
[h.span([], ['Solutions'])],
),
...(isVisible
? [
h.div([...backdrop, h.Class('fixed inset-0')], []),
h.div(
[...panel, h.Class('rounded-lg border shadow-lg p-4 w-80')],
[
h.h3([h.Class('font-medium')], ['Analytics']),
h.p(
[h.Class('text-sm text-gray-500')],
[
'Get a better understanding of where your traffic is coming from.',
],
),
],
),
]
: []),
],
),
},
toParentMessage: message => GotPopoverMessage({ message }),
})
}Pass isAnimated: true at init for animation coordination.
Popover is headless. The toView callback receives attribute bundles for the button, panel, and backdrop, and the consumer composes the markup.
When isAnimated is true, enter/leave animations flow through the Animation module. Style with CSS transitions or CSS keyframe animations. Animation advances once every animation on the element has settled.
| Attribute | Condition |
|---|---|
data-open | Present on button and panel when open. |
data-disabled | Present on the button when disabled. |
data-closed | Present during close animation. |
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.
| Key | Description |
|---|---|
| Enter / Space | Toggles the popover. |
| Escape | Closes the popover and returns focus to the button. |
| Tab | Navigates within the panel. Closes the popover when focus leaves. |
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.
Configuration object passed to Popover.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the popover instance. |
isAnimated | boolean | false | Enables animation coordination. |
isModal | boolean | false | Locks page scroll and marks other elements inert when open. |
contentFocus | boolean | false | Hands focus ownership to the consumer. When true, the panel is not focusable and does not close on blur; the consumer must focus a descendant on open and decide on its own blur rules. |
Configuration object passed to Popover.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Popover.Model | - | The popover state from your parent Model. |
toParentMessage | (childMessage: Popover.Message) => ParentMessage | - | Wraps Popover Messages in your parent Message type for Submodel delegation. |
anchor | AnchorConfig | - | Floating positioning config: placement, gap, and padding. Required. |
toView | (render: RenderInfo) => Html | - | Callback that receives the button, panel, and backdrop attribute bundles plus a derived `isVisible` flag, and returns the composed layout. |
isDisabled | boolean | false | Disables the trigger button. |
focusSelector | string | - | CSS selector for the element to focus after the panel is positioned. Defaults to the panel itself. |
Payload delivered to the toView callback each render.
| Name | Type | Default | Description |
|---|---|---|---|
button | ReadonlyArray<ChildAttribute> | - | Spread onto the trigger button. Includes the button id, `aria-expanded`, `aria-controls`, and pointer/keyboard handlers. |
panel | ReadonlyArray<ChildAttribute> | - | Spread onto the floating panel. Includes the anchor Mount that positions the panel via Floating UI, ARIA linkage to the button, and panel keydown/blur handlers. |
backdrop | ReadonlyArray<ChildAttribute> | - | Spread onto the modal backdrop element. Includes the portal Mount that moves the backdrop to `document.body`. The backdrop's click handler dispatches `RequestedClose`. |
isVisible | boolean | - | Derived from `isOpen` and the Animation `transitionState`. Render the panel and backdrop only while this is true. |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.
| Name | Type | Default | Description |
|---|---|---|---|
Opened | {} | - | Emitted once the popover has transitioned to open. Fires after `update` has processed `RequestedOpen` and `isOpen` reflects the new state. |
Closed | {} | - | Emitted once the popover has transitioned to closed. Programmatic `Popover.close` on an already-closed model is a no-op that does not re-emit. |