Latticecomponents

Popover

Anchored surface primitive that owns open state, popper positioning, layered dismissal, and presence motion while you own the visuals.

@lattice-ui/react-popoverStable directionimport Popoverdepends on runtime, focus, layer, motion, popper

Popover is the primitive for a non-blocking surface that floats next to the element that opened it: hover cards, inline editors, detail panels, and small forms. It coordinates open state, positioning, dismissal, and exit motion so your component only has to render the floating frame and its contents.

Reach for Popover when a surface should anchor to a trigger (or a separate anchor), position itself with the popper foundation, and dismiss predictably — by an explicit close, an outside interaction, or a controlled state change. Unlike Dialog, Popover is non-modal by default, so the rest of the UI stays interactive while it is open.

Preview

The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.

Edit

Import

import { Popover } from "@lattice-ui/react-popover";

Anatomy

Compose the parts you need. Root, Portal, and Content form the minimum useful surface; Trigger, Anchor, and Close are optional depending on how you drive and position the popover.

Popover anatomy

Popover anatomy
<Popover.Root>
<Popover.Trigger />
<Popover.Anchor />
<Popover.Portal>
<Popover.Content>
<Popover.Close />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
PartRequiredResponsibility
Popover.RootyesOwns open state and shares it with every part through context.
Popover.TriggernoA button that toggles the popover and acts as the default positioning anchor.
Popover.AnchornoAn explicit anchor to position against when the trigger is not the right reference.
Popover.PortalyesRenders the surface into a ScreenGui outside the local tree.
Popover.ContentyesThe positioned, dismissable, motion-driven surface.
Popover.ClosenoA button that closes the popover from inside the content.

Examples

Basic popover

The smallest useful popover: an uncontrolled root, a trigger projected onto your own button with asChild, and a content surface in a portal. The trigger toggles on activation and doubles as the positioning anchor, an outside press dismisses, and the default content wrapper is an auto-sized frame — so your frame inside it just needs a size.

EmoteInfoPopover.tsx
import { Popover } from "@lattice-ui/react-popover";
export function EmoteInfoPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<textbutton Text="Emote details" Size={UDim2.fromOffset(140, 38)} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content>
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(220, 96)}>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(200, 80)}
Text="Wave — unlocked at level 5"
TextColor3={Color3.fromRGB(236, 241, 249)}
TextWrapped={true}
/>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

Detached anchor

Add a Popover.Anchor when the surface should point at something other than the button that opens it. Here a help button in a toolbar opens a callout that annotates the quest tracker HUD frame instead of the button itself. A mounted anchor always wins over the trigger as the positioning reference, and with asChild it tracks your element directly instead of the frame it renders.

QuestTrackerCallout.tsx
import { Popover } from "@lattice-ui/react-popover";
export function QuestTrackerCallout() {
return (
<Popover.Root>
<Popover.Anchor asChild>
<frame
AnchorPoint={new Vector2(1, 0)}
BackgroundColor3={Color3.fromRGB(24, 26, 32)}
Position={UDim2.new(1, -16, 0, 16)}
Size={UDim2.fromOffset(240, 110)}
>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(220, 24)}
Text="Active quest: Ember Vault"
TextColor3={Color3.fromRGB(236, 241, 249)}
/>
</frame>
</Popover.Anchor>
<Popover.Trigger asChild>
<textbutton Text="What is this?" Size={UDim2.fromOffset(110, 30)} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content placement="left" sideOffset={12}>
<frame BackgroundColor3={Color3.fromRGB(30, 33, 41)} Size={UDim2.fromOffset(200, 90)}>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(180, 74)}
Text="The quest tracker shows your current objective and reward."
TextColor3={Color3.fromRGB(236, 241, 249)}
TextWrapped={true}
/>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

Controlled by game state

Pass open and onOpenChange when something outside the popover decides visibility. This onboarding hint opens the moment the player receives their first item and closes through the same channel a trigger would use — an outside press or Popover.Close still calls onOpenChange(false), so your state stays the source of truth. No trigger is needed; the anchor alone positions the surface.

InventoryHint.tsx
import { Popover } from "@lattice-ui/react-popover";
export function InventoryHint(props: { hasNewItem: boolean; onDismiss: () => void }) {
return (
<Popover.Root
open={props.hasNewItem}
onOpenChange={(open) => {
if (!open) {
props.onDismiss();
}
}}
>
<Popover.Anchor asChild>
<imagebutton
BackgroundColor3={Color3.fromRGB(30, 33, 41)}
Image="rbxassetid://1234567890"
Size={UDim2.fromOffset(48, 48)}
/>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content placement="top" sideOffset={10}>
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(200, 72)}>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(180, 56)}
Text="New item! Open your inventory to equip it."
TextColor3={Color3.fromRGB(236, 241, 249)}
TextWrapped={true}
/>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

Placement and offsets

Positioning is tuned entirely on Popover.Content. placement requests a side (default "bottom"), sideOffset adds a gap between the anchor and the surface, alignOffset shifts the surface along the anchor’s cross axis, and collisionPadding sets the minimum distance kept from the screen edge (default 8). When the requested side does not fit, the content flips to the opposite side — and its entrance motion animates from whichever side actually resolved.

StatTooltipPopover.tsx
import { Popover } from "@lattice-ui/react-popover";
export function StatTooltipPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<textbutton Text="Attack: 42" Size={UDim2.fromOffset(120, 32)} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content placement="right" sideOffset={8} alignOffset={-4} collisionPadding={16}>
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(180, 64)}>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(160, 48)}
Text="Base 30 + weapon 12. Scales with strength."
TextColor3={Color3.fromRGB(236, 241, 249)}
TextWrapped={true}
/>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

Closing from inside

Popover.Close closes the popover from anywhere inside the content; with asChild it merges the close behavior onto your own button. On close, the focus scope restores gamepad selection to whatever was selected before the popover opened — the trigger focuses itself as it opens, so selection lands back on the trigger without any wiring on your side.

LoadoutSavePopover.tsx
import { Popover } from "@lattice-ui/react-popover";
export function LoadoutSavePopover(props: { onSave: () => void }) {
return (
<Popover.Root>
<Popover.Trigger asChild>
<textbutton Text="Save loadout" Size={UDim2.fromOffset(140, 38)} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content placement="bottom" sideOffset={6}>
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(220, 110)}>
<uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(200, 28)}
Text="Overwrite slot 1?"
TextColor3={Color3.fromRGB(236, 241, 249)}
/>
<Popover.Close asChild>
<textbutton
BackgroundColor3={Color3.fromRGB(88, 142, 255)}
Event={{ Activated: props.onSave }}
Size={UDim2.fromOffset(100, 32)}
Text="Save"
TextColor3={Color3.fromRGB(240, 244, 252)}
/>
</Popover.Close>
<Popover.Close asChild>
<textbutton Size={UDim2.fromOffset(100, 32)} Text="Cancel" />
</Popover.Close>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

Custom exit motion

Popover.Content runs no motion of its own. Pass a transition to animate it, and forceMount when the node should stay mounted instead of unmounting after the exit finishes — useful when you drive motion yourself or need the instance to persist. The wrapper is a frame with or without asChild, so createPopperEntranceRecipe is the recipe that matches it.

SlowRevealPopover.tsx
import { Popover } from "@lattice-ui/react-popover";
import { createPopperEntranceRecipe } from "@lattice-ui/react-motion";
const SLOW_REVEAL = createPopperEntranceRecipe("top", 16, 0.25);
export function SlowRevealPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<textbutton Text="Match summary" Size={UDim2.fromOffset(140, 38)} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content placement="top" sideOffset={10} transition={SLOW_REVEAL} forceMount>
<frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(240, 120)}>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(220, 100)}
Text="Victory — 12 eliminations"
TextColor3={Color3.fromRGB(236, 241, 249)}
/>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

How it behaves

Open state

Popover.Root is controllable. Pass open and onOpenChange to control it, or defaultOpen to run uncontrolled (defaults to closed). Popover.Trigger toggles the open state on activation and Popover.Close closes it; outside-press dismissal goes through the same setOpen path. Everything funnels into one state, so controlled and uncontrolled usage behave identically.

Trigger and anchor resolution

The trigger registers itself as the positioning anchor, but only while no Popover.Anchor has claimed the slot — a mounted anchor always takes precedence, whether it mounts before or after the trigger. The default trigger is a 150x38 textbutton labeled “Toggle Popover”; with asChild its Active, Activated handler, Selectable={false}, and ref are merged onto your single child element. disabled blocks toggling and removes the trigger from focus tracking. Popover.Anchor renders a zero-size transparent frame by default; with asChild it tracks your element’s geometry directly and renders nothing extra.

Positioning

Popover.Content is positioned by the popper foundation. It measures the anchor and the content, then resolves a final placement, flipping to the opposite side when the requested side would collide with the screen edge. Tune it with placement ("top" | "bottom" | "left" | "right", default "bottom"), sideOffset (gap from the anchor, default 0), alignOffset (shift along the anchor’s cross axis, default 0), and collisionPadding (minimum distance from the screen edge, default 8).

Until the first measurement resolves, the surface is parked far offscreen and the reveal motion is held back — the content never flashes at an unpositioned location, and the entrance always animates from the placement that actually resolved, not the one you requested.

Focus and selection

Popover.Content mounts a focus scope tied to the open state. Because Popover is non-modal by default, the scope is not trapped — gamepad and GuiObject selection can move freely between the popover and the rest of the screen. The trigger focuses itself just before opening, and the scope restores focus to the previously selected object on close, so selection returns to the trigger without extra wiring. Setting modal on the Root switches the scope to trapped, keeping selection inside the surface while it is open.

Dismissal

Popover.Content participates in dismissable-layer behavior while open. An outside press dismisses it, and when modal is true, interaction behind the surface is also blocked. Use onPointerDownOutside (pointer presses) and onInteractOutside (any other outside interaction) to observe or veto those interactions before the popover closes.

Motion and presence

Popover.Content runs no motion unless you pass a transition. createPopperEntranceRecipe(placement) from @lattice-ui/react-motion slides the surface 10 pixels in from the resolved placement side while fading BackgroundTransparency, on the default wrapper and under asChild alike. It takes its own distance and duration, and exits at 0.8x the reveal.

Without forceMount, the content mounts when the popover opens and unmounts after the exit motion completes. Pass forceMount to skip the presence wrapper entirely: the node stays mounted while closed with its Visible driven by the motion controller, which is useful when you drive motion yourself or need the instance to persist across open cycles.

API reference

Popover.Root

PropTypeDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanInitial open state for uncontrolled usage. Defaults to false.
onOpenChange(open: boolean) => voidCalled whenever the open state changes — from the trigger, a close button, outside dismissal, or a controlled update.
modalbooleanWhen true, blocks interaction behind the popover and traps focus inside it. Defaults to false.
childrenReact.ReactNodeThe popover parts.

Popover.Trigger

PropTypeDescription
asChildbooleanMerge toggle behavior, focus tracking, and the anchor ref onto the single child element instead of the textbutton the part renders.
disabledbooleanPrevents the trigger from toggling the popover and removes it from focus tracking.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Popover.Anchor

PropTypeDescription
asChildbooleanTrack the single child element's geometry instead of the frame the part renders. A mounted anchor takes positioning precedence over the trigger.
childrenReact.ReactElementThe element to anchor against. Required when asChild is set.

Popover.Portal

PropTypeDescription
containerBasePlayerGuiTarget PlayerGui to render the surface into. Defaults to the surrounding portal context's container.
displayOrderBasenumberBase DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value.
childrenReact.ReactNodeThe content part.

Popover.Content

PropTypeDescription
placement"top" | "bottom" | "left" | "right"Requested side to position the content on. Flips to the opposite side on collision. Defaults to "bottom".
sideOffsetnumberGap in pixels between the anchor and the content. Defaults to 0.
alignOffsetnumberShift in pixels along the anchor's cross axis. Defaults to 0.
collisionPaddingnumberMinimum distance in pixels to keep from the screen edge. Defaults to 8.
asChildbooleanPosition and animate the single child element instead of the auto-sized frame wrapper the part renders. createPopperEntranceRecipe fits either path; supply a canvasgroup here if you want the whole subtree to fade as one layer.
forceMountbooleanKeeps the content mounted while closed and through exit motion, with Visible driven by the motion controller, instead of unmounting after exit.
transitionPresenceMotionConfigReveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, on the default path and under asChild alike.
onPointerDownOutside(event: LayerInteractEvent) => voidCalled when a pointer press occurs outside the content, before dismissal.
onInteractOutside(event: LayerInteractEvent) => voidCalled for any other outside interaction, before dismissal.
childrenReact.ReactNodeThe surface contents. Must be a single valid element when asChild is set.

Popover.Close

PropTypeDescription
asChildbooleanMerge close behavior onto the single child element instead of the textbutton the part renders. Event handlers compose, so the child's own Activated handler still runs.
childrenReact.ReactElementThe element to render. Required when asChild is set.