Popover
Display rich content in a panel, triggered by a button.
import { Div, H4, Input, Label, P } from "@implementjs/core";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@/lib/components/ui/popover";
function Field(id: string, label: string, value: string) {
return Div(
{ class: "grid grid-cols-3 items-center gap-4" },
Label({ for: id, class: "text-sm" }, label),
Input({
id,
value,
class:
"col-span-2 h-8 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
}),
);
}
export default function PopoverDemo() {
return Popover(
PopoverTrigger({ variant: "outline" }, "Open popover"),
PopoverPortal(
PopoverContent(
{ class: "w-80" },
Div(
{ class: "grid gap-4" },
Div(
{ class: "space-y-2" },
H4({ class: "leading-none font-medium" }, "Dimensions"),
P({ class: "text-sm text-muted-foreground" }, "Set the dimensions for the layer."),
),
Div(
{ class: "grid gap-2" },
Field("width", "Width", "100%"),
Field("maxWidth", "Max. width", "300px"),
Field("height", "Height", "25px"),
Field("maxHeight", "Max. height", "none"),
),
PopoverClose({ variant: "outline", class: "w-full" }, "Done"),
),
),
),
);
}A popover is a small panel that opens from a button. Popover is the root, PopoverTrigger is the control that toggles it, and PopoverContent is the panel. Wrap the panel in PopoverPortal when it needs to escape overflow, and put PopoverClose inside the panel for a dismiss control.
import {
Popover,
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@implementjs/primitives";
Popover(
PopoverTrigger("Open popover"),
PopoverPortal(PopoverContent("Place content for the popover here.", PopoverClose("Done"))),
);
Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props on the trigger, content, and close are forwarded onto the underlying Button or Div.
Open state
Popover owns whether the panel is open. Pass a boolean to seed it, or a signal to control it from outside (signal() returns a writable unchanged, so the same prop accepts both):
const open = signal(false);
Popover({ open }, PopoverTrigger("Open popover"), PopoverContent("Hello"));
Button({ onClick: () => open.set(false) }, "Close");
If it starts open (open: true, or a signal that's already true) the panel anchors to the first trigger in the tree, or the one marked default. Positioning runs on mount, once the nodes are in the document.
The page behind stays scrollable while the popover is open. Pass preventScroll: true to lock it.
The trigger and the content
PopoverTrigger renders a Button. Clicking it toggles the popover. PopoverContent is a Div that holds whatever you put in the panel: text, a form, another component.
Portal
PopoverPortal is the Portal helper under a popover name. It renders its children into document.body by default so the panel is not clipped by overflow or trapped in a parent stacking context. Context still resolves from where you declared it.
Wrap PopoverContent in it. Chain .To(target) or pass to to pick a different parent, and disabled to mount in place instead. Nested popovers typically disable the inner portal so the nested panel stays in the outer overlay:
PopoverPortal(PopoverContent("Hello"));
PopoverPortal({ to: overlayRoot, disabled: nested }, PopoverContent("Hello"));
Close
PopoverClose is a Button that sets the popover closed. Put it inside the content for a Done or dismiss control. You can still close from outside by writing the open signal.
PopoverContent("Place content for the popover here.", PopoverClose("Done"));
Multiple triggers
A popover can have more than one trigger. They share a single panel, and clicking a trigger opens it against that button. Click the same trigger again to close; click a different one to move the panel.
When the popover starts open, it still has to pick an anchor. That's the first trigger in the tree, unless you pass default on a different one:
Popover(
{ open: true },
PopoverTrigger("Left"),
PopoverTrigger({ default: true }, "Center"),
PopoverTrigger("Right"),
PopoverContent("Starts open against Center."),
);
import { Div, P } from "@implementjs/core";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@/lib/components/ui/popover";
export default function PopoverTriggersDemo() {
return Div(
{ class: "flex flex-wrap items-center justify-center gap-2" },
Popover(
PopoverTrigger({ variant: "outline" }, "Left"),
PopoverTrigger({ variant: "outline" }, "Center"),
PopoverTrigger({ variant: "outline" }, "Right"),
PopoverPortal(
PopoverContent(
{ class: "w-64" },
P({ class: "text-sm" }, "Opened from whichever trigger you clicked."),
),
),
),
);
}Nested
Each Popover provides its own context, so a second root inside the content talks to its own trigger, panel, and close. Put the inner trigger in the outer panel.
Disable the inner portal. If both teleport to document.body, closing the outer popover hides its content but leaves the inner panel on the page.
Popover(
PopoverTrigger("Open popover"),
PopoverPortal(
PopoverContent(
"This is the outer popover.",
Popover(
PopoverTrigger("Open nested"),
PopoverPortal(
{ disabled: true },
PopoverContent({ side: "right" }, "This is nested inside the first one."),
),
),
),
),
);
import { Div, P } from "@implementjs/core";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@/lib/components/ui/popover";
export default function PopoverNestedDemo() {
return Popover(
PopoverTrigger({ variant: "outline" }, "Open popover"),
PopoverPortal(
PopoverContent(
{ class: "w-64" },
Div(
{ class: "grid gap-3" },
P({ class: "text-sm" }, "This is the outer popover."),
Popover(
PopoverTrigger({ variant: "outline", class: "w-full" }, "Open nested"),
PopoverPortal(
{ disabled: true },
PopoverContent(
{ class: "w-56", side: "right" },
P({ class: "text-sm" }, "This is nested inside the first one."),
),
),
),
),
),
),
);
}Styling
Trigger and content expose data-state as "open" or "closed". Content also sets data-side ("top", "bottom", "left", "right") so motion can slide in from the trigger.
Positioning writes CSS variables on the content: --bits-popover-content-transform-origin for origin-aware scale, --bits-popover-anchor-width / --bits-popover-anchor-height to match the trigger, and --bits-popover-content-available-width / --bits-popover-content-available-height to stay inside the viewport.
PopoverTrigger({ class: "rounded-md border px-3 py-2 text-sm" }, "Open popover");
PopoverContent(
{
class:
"z-50 w-72 origin-(--bits-popover-content-transform-origin) max-h-(--bits-popover-content-available-height) rounded-md border bg-popover p-4 text-sm shadow-md transition data-[state=closed]:hidden data-[state=closed]:data-[side=bottom]:-translate-y-2",
},
"Place content for the popover here.",
);
data-state is there for visibility and open versus closed. data-side is the actual placed side (after flip), so enter and exit stay pointed at the trigger.
API Reference
Popover
The root. Owns whether the popover is open and provides that to the parts inside it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
preventScroll | boolean | false | When true, the page behind cannot scroll while the popover is open. The panel can still scroll if you give it overflow. |
PopoverTrigger
Toggles the popover open and closed. Clicking a different trigger moves the panel. Renders a Button; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
default | boolean | false | When the popover starts open, anchor to this trigger instead of the first one in the tree. |
| Data attribute | Value |
|---|---|
[data-state] | "open" | "closed" |
PopoverContent
The panel. Style it against data-state and data-side; the primitive does not hide it for you. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Preferred side of the trigger to place the panel. |
align | "start" | "center" | "end" | "start" | How the panel aligns along the chosen side. |
offset | number | 0 | Distance in pixels between the trigger and the panel. |
| Data attribute | Value |
|---|---|
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
| CSS variable | Description |
|---|---|
--bits-popover-content-transform-origin | The transform origin of the content element. |
--bits-popover-content-available-width | The available width of the content element. |
--bits-popover-content-available-height | The available height of the content element. |
--bits-popover-anchor-width | The width of the anchor element. |
--bits-popover-anchor-height | The height of the anchor element. |
PopoverPortal
Renders its children into another DOM parent so the panel escapes overflow and stacking. This is the core Portal helper; context still resolves from where the portal is declared.
| Prop | Type | Default | Description |
|---|---|---|---|
to | HTMLElement | Readable<HTMLElement> | document.body | The element to mount into. Also available as chained .To(target). |
disabled | boolean | Readable<boolean> | false | Mount in place instead of teleporting. Disable the inner portal on a nested popover so it stays in the outer overlay. Also available as chained .Disabled(value). |
PopoverClose
Closes the popover when clicked. Put it inside the content. Renders a Button; extra props are forwarded onto it.