Latticecomponents

Popover

An anchored, non-modal surface with popper positioning and layered dismissal.

@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 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

Edit

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 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

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.

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. A mounted anchor always wins over the trigger, and with asChild it tracks your element directly.

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 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.

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 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.

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, 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.

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 to keep the node mounted after the exit. The wrapper is a frame either way, so createPopperEntranceRecipe 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

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.

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.