Skip to content
Ballpoint
Contents

Popover

A slip of paper held up next to its trigger, pencilled round and lifted off its shadow.

Built on Base UI Popover

Installation

pnpm dlx shadcn@latest add @ballpoint/popover
Or copy the source
ui/popover.tsx
"use client";

import type { ComponentProps } from "react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { cn } from "@/lib/utils";
import { usePen, type Pen } from "@/hooks/use-ink-box";
import { InkPanel } from "@/lib/ink-panel";

function Popover({ ...props }: PopoverPrimitive.Root.Props) {
  return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}

function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}

/** A slip of paper held up next to its trigger, pencilled round and lifted off its shadow. */
function PopoverContent({
  className,
  children,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 8,
  seed,
  roughness,
  passes,
  radius,
  corners,
  shadow,
  draw,
  weight,
  speed,
  ...props
}: PopoverPrimitive.Popup.Props &
  Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> &
  Omit<Pen, "fill"> & { seed?: string | number }) {
  const pen = usePen({ roughness, passes, radius, corners, shadow, draw, weight, speed });
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset} className="isolate z-50">
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          className={cn(
            "ink-paper relative isolate flex w-72 origin-(--transform-origin) flex-col gap-3 p-4 text-base text-popover-foreground outline-none",
            "transition-[opacity,scale] duration-(--dur-hover) ease-out data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:ease-in",
            "motion-safe:data-starting-style:scale-[0.97] motion-safe:data-ending-style:scale-[0.98]",
            className,
          )}
          {...props}
        >
          <InkPanel pen={pen} seed={seed} estimate={[288, 140]} offset={5} className="text-ink-3" />
          {children}
        </PopoverPrimitive.Popup>
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  );
}

function PopoverHeader({ className, ...props }: ComponentProps<"div">) {
  return <div data-slot="popover-header" className={cn("flex flex-col gap-1", className)} {...props} />;
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return <PopoverPrimitive.Title data-slot="popover-title" className={cn("text-lg leading-snug font-bold", className)} {...props} />;
}

function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
  return <PopoverPrimitive.Description data-slot="popover-description" className={cn("text-base text-ink-3", className)} {...props} />;
}

export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };

Usage

tsx
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>Open</PopoverTrigger>
  <PopoverContent>Place content here.</PopoverContent>
</Popover>

API reference

side"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end"default "bottom"
On PopoverContent: which side of the trigger it opens on.
align"start" | "center" | "end"default "center"
How it lines up with the trigger along that side.
sideOffset / alignOffsetnumberdefault 8 / 0
Gap from the trigger, and nudge along it, in px.
seedstring | number
Pins the drawing.

Every other prop goes to Base UI's Popover.

Pen settings

The drawing also takes draw, roughness, passes, radius, corners, shadow, weight and speed, as props or from the nearest InkProvider. What each one does.