implement
Popover

Popover

A floating panel anchored to whatever opened it.

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"),
				),
			),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/popover

jsrepo pulls button along with it.

Usage

An 18rem panel that fades and scales in from the side it opens on, with the transform origin following the side the primitive actually chose after flipping. Unlike a dropdown menu, the content is ordinary interactive markup — forms, inputs, anything.

PopoverTrigger and PopoverClose both render through the button styles, defaulting to default and to a small ghost respectively.

import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@/lib/components/ui/popover";

Popover(
	PopoverTrigger({ variant: "outline" }, "Open popover"),
	PopoverContent(
		H4({ class: "font-medium" }, "Dimensions"),
		Input({ placeholder: "Width" }),
		PopoverClose("Done"),
	),
);

Several triggers, one popover

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."),
				),
			),
		),
	);
}

Put more than one PopoverTrigger in a popover and the panel anchors to whichever was clicked. Useful for a row of cells that all open the same editor.

Nesting

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."),
							),
						),
					),
				),
			),
		),
	);
}

A popover opened from inside another stays open with its parent, and closes with it. The primitive tracks the depth, so the inner panel anchors to its own trigger rather than to the outer one.

Placement

side, align, and offset live on the content, and the styled defaults are bottom / start / 5. The primitive flips the panel when it would overflow the viewport, and writes the side it settled on to data-side — which is what the enter transition reads, so the panel always animates out of its anchor.

API Reference

Every prop the styling does not consume is forwarded to the Popover primitive, so the tables below are the whole surface — the behavior props and the styling ones together.

Popover

The root. Owns whether the popover is open and provides that to the parts inside it.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.
preventScrollbooleanfalseWhen 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.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""default"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"The button's height and padding scale. Also set as data-size.
defaultbooleanfalseWhen the popover starts open, anchor to this trigger instead of the first one in the tree.
Data attributeValue
[data-state]"open" | "closed"

PopoverContent

The floating panel. Styled as an 18rem popover surface that fades and scales in from the side it opens on, and hides itself when closed. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
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.
offsetnumber5Distance in pixels between the trigger and the panel.
Data attributeValue
[data-state]"open" | "closed"
[data-side]"top" | "bottom" | "left" | "right"
[data-align]"start" | "center" | "end"
CSS variableDescription
--bits-popover-content-transform-originThe transform origin of the content element.
--bits-popover-content-available-widthThe available width of the content element.
--bits-popover-content-available-heightThe available height of the content element.
--bits-popover-anchor-widthThe width of the anchor element.
--bits-popover-anchor-heightThe 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.

PropTypeDefaultDescription
toHTMLElement | Readable<HTMLElement>document.bodyThe element to mount into. Also available as chained .To(target).
disabledboolean | Readable<boolean>falseMount 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.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""ghost"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""sm"The button's height and padding scale. Also set as data-size.

Dimensions

Set the dimensions for the layer.

Opened from whichever trigger you clicked.

This is the outer popover.

This is nested inside the first one.