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.
Copy the file below to src/lib/components/ui/popover.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too.
import type { Child, ComponentProps } from "@implementjs/core";
import {
Popover as PopoverPrimitive,
PopoverClose as PopoverClosePrimitive,
PopoverContent as PopoverContentPrimitive,
PopoverPortal as PopoverPortalPrimitive,
PopoverTrigger as PopoverTriggerPrimitive,
} from "@implementjs/primitives";
import { buttonVariants, type ButtonSize, type ButtonVariant } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type PopoverProps = ComponentProps<typeof PopoverPrimitive>;
export type PopoverTriggerProps = ComponentProps<typeof PopoverTriggerPrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type PopoverContentProps = ComponentProps<typeof PopoverContentPrimitive>;
export type PopoverCloseProps = ComponentProps<typeof PopoverClosePrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export const PopoverPortal = PopoverPortalPrimitive;
export const Popover = createComponent(function Popover(props: PopoverProps, ...children: Child[]) {
return PopoverPrimitive(props, ...children);
});
export const PopoverTrigger = createComponent(function PopoverTrigger(
{
class: className,
variant = "default",
size = "default",
type = "button",
...props
}: PopoverTriggerProps,
...children: Child[]
) {
return PopoverTriggerPrimitive(
{
type,
...props,
"data-slot": "popover-trigger",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});
export const PopoverContent = createComponent(function PopoverContent(
{ offset = 5, side = "bottom", align = "start", class: className, ...props }: PopoverContentProps,
...children: Child[]
) {
return PopoverContentPrimitive(
{
...props,
"data-slot": "popover-content",
offset,
side,
align,
class: cn(
"absolute z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"origin-(--ip-popover-content-transform-origin)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:translate-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"starting:data-[state=open]:data-[side=bottom]:-translate-y-2 starting:data-[state=open]:data-[side=top]:translate-y-2 starting:data-[state=open]:data-[side=left]:translate-x-2 starting:data-[state=open]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});
export const PopoverClose = createComponent(function PopoverClose(
{
class: className,
variant = "ghost",
size = "sm",
type = "button",
...props
}: PopoverCloseProps,
...children: Child[]
) {
return PopoverClosePrimitive(
{
type,
...props,
"data-slot": "popover-close",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});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.
| 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 |
|---|---|---|---|
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. |
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 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.
| 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 | 5 | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |