@lattice-ui/react-popoverStable directionimport Popoverdepends on runtime, focus, layer, motion, popperPopover is the primitive for a non-blocking surface that floats next to whatever opened it: hover cards, inline editors, detail panels, small forms. It anchors to a trigger or a separate anchor, positions itself with the popper foundation, and dismisses predictably. Unlike Dialog it is non-modal by default, so the rest of the UI stays interactive.
Preview
import { Popover } from "@lattice-ui/react-popover";import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { buttonRecipe, panelRecipe } from "../../../playground/src/client/theme/recipes";
function PopoverExample() { const { theme } = useTheme();
const rows: Array<{ label: string; value: string }> = [ { label: "Width", value: "100%" }, { label: "Max. width", value: "300px" }, { label: "Height", value: "25px" }, ];
return ( <Popover.Root> <Popover.Trigger asChild> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "surface", size: "md" }, theme), { Size: UDim2.fromOffset(160, 40), Text: "Open popover", TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> </textbutton> </Popover.Trigger>
<Popover.Portal> <Popover.Content asChild placement="bottom" sideOffset={8}> <frame {...(mergeGuiProps(panelRecipe({ tone: "elevated" }, theme), { Size: UDim2.fromOffset(260, 188), }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[16])} PaddingLeft={new UDim(0, theme.space[16])} PaddingRight={new UDim(0, theme.space[16])} PaddingTop={new UDim(0, theme.space[16])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[10])} />
<frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(228, 36)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(228, 16)} Text="Dimensions" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 20)} Size={UDim2.fromOffset(228, 16)} Text="Set the dimensions for the layer." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame>
{rows.map((row, index) => ( <frame BackgroundTransparency={1} key={row.label} LayoutOrder={index + 1} Size={UDim2.fromOffset(228, 30)} > <Text BackgroundTransparency={1} Size={UDim2.fromOffset(84, 30)} Text={row.label} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} Position={UDim2.fromOffset(92, 0)} Size={UDim2.fromOffset(136, 30)} > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingLeft={new UDim(0, theme.space[10])} /> <Text BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={row.value} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> </frame> ))} </frame> </Popover.Content> </Popover.Portal> </Popover.Root> );}Import
import { Popover } from "@lattice-ui/react-popover";Anatomy
Root, Portal and Content are the minimum useful surface. Trigger, Anchor and Close
depend on how you drive and position it.
Popover anatomy
<Popover.Root> <Popover.Trigger /> <Popover.Anchor /> <Popover.Portal> <Popover.Content> <Popover.Close /> </Popover.Content> </Popover.Portal></Popover.Root>| Part | Required | Responsibility |
|---|---|---|
Popover.Root | yes | Owns open state and shares it with every part through context. |
Popover.Trigger | no | A button that toggles the popover and acts as the default positioning anchor. |
Popover.Anchor | no | An explicit anchor to position against when the trigger is not the right reference. |
Popover.Portal | yes | Renders the surface into a ScreenGui outside the local tree. |
Popover.Content | yes | The positioned, dismissable, motion-driven surface. |
Popover.Close | no | A button that closes the popover from inside the content. |
Examples
Basic popover
An uncontrolled root, a trigger projected onto your own button, and a content surface in a portal.
The trigger toggles on activation and doubles as the positioning anchor, and an outside press
dismisses. The content wrapper is an auto-sized frame, so your frame inside needs only a size.
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. A mounted anchor always wins over the trigger, and with asChild it tracks your element
directly.
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 decides visibility. An outside press or
Popover.Close still calls onOpenChange(false), so your state stays the source of truth. No
trigger is needed, because the anchor alone positions the surface.
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 on Popover.Content. placement requests a side, sideOffset adds a gap,
alignOffset shifts along the cross axis, and collisionPadding keeps a minimum distance from the
screen edge. When the requested side does not fit the content flips, and its entrance animates from
whichever side resolved.
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, and with asChild merges that
onto your own button. The focus scope restores selection to whatever was selected before opening,
and the trigger focuses itself as it opens, so selection lands back on it.
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 to
keep the node mounted after the exit. The wrapper is a frame either way, so
createPopperEntranceRecipe matches it.
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
Pass open and onOpenChange to control it, or defaultOpen to run uncontrolled. Trigger
toggles on activation, Close closes, and outside-press dismissal goes through the same setOpen
path.
Trigger and anchor resolution
The trigger registers as the positioning anchor only while no Popover.Anchor has claimed the slot
— a mounted anchor always takes precedence. Under asChild the trigger’s Active, Activated
handler, Selectable={false} and ref merge onto your child, and disabled blocks toggling and
removes it from focus tracking. Popover.Anchor renders a zero-size transparent frame, or tracks
your element’s geometry under asChild.
Positioning
The popper foundation measures the anchor and the content, then resolves a placement, flipping when the requested side would collide with a screen edge.
Until the first measurement resolves, the surface is parked offscreen and the reveal held back, so the entrance animates from the placement that actually resolved.
Focus and selection
Popover.Content mounts a focus scope tied to the open state. Non-modal by default it is not
trapped, so selection moves freely between the popover and the rest of the screen. The trigger
focuses itself just before opening, and the scope restores focus on close. modal on the Root
traps the scope instead.
Dismissal
An outside press dismisses the content, and modal also blocks interaction behind the surface.
onPointerDownOutside and onInteractOutside observe or veto first.
Motion and presence
createPopperEntranceRecipe(placement) slides the surface 10 pixels in from the resolved side while
fading BackgroundTransparency, with or without asChild. It takes its own distance and duration,
and exits at 0.8x the reveal.
Without forceMount the content mounts on open and unmounts after the exit. With it, the node stays
mounted while closed with Visible driven by the motion controller.
Popover defaults to modal={false}, floating over the UI without blocking it or trapping selection. Set modal only when it should behave like Dialog.
With both mounted, the content positions against the anchor. There is no prop to flip it — remove the anchor if the trigger should be the reference.
BackgroundTransparency fades one instance’s own background, so the surface fades while the labels and icons inside stay opaque. Fade those alongside it, or pass asChild with your own canvasgroup and createCanvasGroupPopperEntranceRecipe, which fades the whole subtree as one composited layer.
API reference
Popover.Root
| Prop | Type | Description |
|---|---|---|
| open | boolean | Controlled open state. Pair with onOpenChange. |
| defaultOpen | boolean | Initial open state for uncontrolled usage. Defaults to false. |
| onOpenChange | (open: boolean) => void | Called whenever the open state changes — from the trigger, a close button, outside dismissal, or a controlled update. |
| modal | boolean | When true, blocks interaction behind the popover and traps focus inside it. Defaults to false. |
| children | React.ReactNode | The popover parts. |
Popover.Trigger
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge toggle behavior, focus tracking, and the anchor ref onto the single child element instead of the textbutton the part renders. |
| disabled | boolean | Prevents the trigger from toggling the popover and removes it from focus tracking. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Popover.Anchor
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Track the single child element's geometry instead of the frame the part renders. A mounted anchor takes positioning precedence over the trigger. |
| children | React.ReactElement | The element to anchor against. Required when asChild is set. |
Popover.Portal
| Prop | Type | Description |
|---|---|---|
| container | BasePlayerGui | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. |
| displayOrderBase | number | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. |
| children | React.ReactNode | The content part. |
Popover.Content
| Prop | Type | Description |
|---|---|---|
| placement | "top" | "bottom" | "left" | "right" | Requested side to position the content on. Flips to the opposite side on collision. Defaults to "bottom". |
| sideOffset | number | Gap in pixels between the anchor and the content. Defaults to 0. |
| alignOffset | number | Shift in pixels along the anchor's cross axis. Defaults to 0. |
| collisionPadding | number | Minimum distance in pixels to keep from the screen edge. Defaults to 8. |
| asChild | boolean | Position 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. |
| forceMount | boolean | Keeps the content mounted while closed and through exit motion, with Visible driven by the motion controller, instead of unmounting after exit. |
| transition | PresenceMotionConfig | Reveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, on the default path and under asChild alike. |
| onPointerDownOutside | (event: LayerInteractEvent) => void | Called when a pointer press occurs outside the content, before dismissal. |
| onInteractOutside | (event: LayerInteractEvent) => void | Called for any other outside interaction, before dismissal. |
| children | React.ReactNode | The surface contents. Must be a single valid element when asChild is set. |
Popover.Close
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge 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. |
| children | React.ReactElement | The element to render. Required when asChild is set. |