Popover

free

Popover floats details, quick forms, and secondary controls next to a trigger.

@floating-ui/react
Preview
Dark
import Button from '@/components/ui/Button';
import Popover from '@/components/ui/Popover';

export default function UsageDemo() {
  return (
    <div className="flex justify-center">
      <Popover title="What's new" width={350} className="p-0">
        <div className="p-2">
          <img
            src="/img/thumbs/misc/img-10.png"
            alt=""
            className="rounded-lg"
          />
        </div>
        <div className="flex flex-col gap-1 p-4">
          <div className="font-semibold">
            New updates just landed
          </div>
          <p className="text-muted-foreground">
            Explore the latest features and find quick answers in the help
            center.
          </p>
        </div>
        <div className="flex items-center justify-end gap-2 p-4">
          <Button variant="ghost" size="sm">
            Maybe later
          </Button>
          <Button variant="solid" size="sm">
            Explore now
          </Button>
        </div>
      </Popover>
    </div>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add Popover

Examples

Basic

Preview
Dark
import { PiArrowUpRight, PiSquaresFour } from 'react-icons/pi'
import Popover from '@/components/ui/Popover';

export default function BasicDemo() {
  return (
    <div className="flex justify-center">
      <Popover title="Components" width={260}>
        <div className="flex flex-col gap-3">
          <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
            <PiSquaresFour className="text-base text-foreground" />
          </span>
          <div className="flex flex-col gap-1">
            <div className="font-semibold text-foreground">
              Consistent by default
            </div>
            <p className="text-sm text-muted-foreground">
              Shared components keep every screen aligned, so teams stop
              rebuilding the same patterns from scratch.
            </p>
          </div>
          <div className="border-t border-border" />
          <a
            href="#"
            className="inline-flex items-center justify-end gap-1 text-sm font-medium hover:underline"
          >
            Learn more
            <PiArrowUpRight className="text-sm" />
          </a>
        </div>
      </Popover>
    </div>
  );
}

Trigger

Preview
Dark
import { PiArrowUpRight, PiSquaresFour } from 'react-icons/pi'
import Popover from '@/components/ui/Popover';

const content = (
  <div className="flex flex-col gap-3">
    <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
      <PiSquaresFour className="text-base text-foreground" />
    </span>
    <div className="flex flex-col gap-1">
      <div className="font-semibold text-foreground">
        Consistent by default
      </div>
      <p className="text-sm text-muted-foreground">
        Shared components keep every screen aligned, so teams stop
        rebuilding the same patterns from scratch.
      </p>
    </div>
    <div className="border-t border-border" />
    <a
      href="#"
      className="inline-flex items-center justify-end gap-1 text-sm font-medium hover:underline"
    >
      Learn more
      <PiArrowUpRight className="text-sm" />
    </a>
  </div>
);

export default function TriggerDemo() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Popover title="Click" width={260}>
        {content}
      </Popover>
      <Popover title="Hover" trigger="hover" width={260}>
        {content}
      </Popover>
    </div>
  );
}

Custom Trigger

Preview
Dark
import { PiSealCheck } from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Tag from '@/components/ui/Tag';
import Popover from '@/components/ui/Popover';

const stats = [
  { value: '4.8', label: 'rating' },
  { value: '$68K', label: 'earned' },
  { value: '$65/hr', label: 'rate' },
];

export default function CustomTriggerDemo() {
  return (
    <div className="flex justify-center">
      <Popover
        width={300}
        className="p-0"
        renderTrigger={
          <button className="flex items-center gap-2">
            <Avatar
              size="sm"
              src="/img/avatars/thumb-13.jpg"
              alt="Maya Ortiz"
            />
            <span className="flex flex-col text-start">
              <span className="font-semibold text-foreground">Maya Ortiz</span>
              <span className="text-muted-foreground">
                Freelance UX Consultant
              </span>
            </span>
          </button>
        }
      >
        <div className="relative h-20 w-full overflow-hidden rounded-t-popover">
          <img
            src="/img/thumbs/misc/img-6.png"
            alt=""
            className="absolute inset-0 h-full w-full object-cover object-top"
          />
        </div>
        <div className="-mt-8 px-4 pb-4">
          <div className="flex items-end justify-between">
            <Avatar
              size={64}
              src="/img/avatars/thumb-13.jpg"
              alt="Maya Ortiz"
              className="border-4 border-popover"
            />
            <Tag className="flex items-center gap-1">
              <span className="h-2 w-2 rounded-full bg-success" />
              Active now
            </Tag>
          </div>
          <div className="mt-4 flex items-center gap-1">
            <span className="font-semibold text-foreground">Maya Ortiz</span>
            <span className="flex h-4 w-4 items-center justify-center text-xl">
              <PiSealCheck className="fill-primary stroke-inverse-foreground " />
            </span>
          </div>
          <p className="text-sm text-muted-foreground">Freelance UX Consultant</p>
          <div className="mt-4 grid grid-cols-3 divide-x divide-border rounded-control py-2">
            {stats.map((stat) => (
              <div
                key={stat.label}
                className="flex flex-col items-center gap-0.5"
              >
                <span className="font-semibold text-foreground">
                  {stat.value}
                </span>
                <span className="text-xs text-muted-foreground">
                  {stat.label}
                </span>
              </div>
            ))}
          </div>
          <Button block variant="solid" className="mt-4">
            Message
          </Button>
        </div>
      </Popover>
    </div>
  );
}

Placement

Preview
Dark
import { PiArrowUpRight, PiSquaresFour } from 'react-icons/pi'
import Popover from '@/components/ui/Popover';

const content = (
  <div className="flex flex-col gap-3">
    <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
      <PiSquaresFour className="text-base text-foreground" />
    </span>
    <div className="flex flex-col gap-1">
      <div className="font-semibold text-foreground">
        Consistent by default
      </div>
      <p className="text-sm text-muted-foreground">
        Shared components keep every screen aligned, so teams stop
        rebuilding the same patterns from scratch.
      </p>
    </div>
    <div className="border-t border-border" />
    <a
      href="#"
      className="inline-flex items-center justify-end gap-1 text-sm font-medium hover:underline"
    >
      Learn more
      <PiArrowUpRight className="text-sm" />
    </a>
  </div>
);

export default function PlacementDemo() {

  const popoverPlacements = [
    {
      title: 'Top start',
      placement: 'top-start'
    },
    {
      title: 'Top',
      placement: 'top'
    },
    {
      title: 'Top end',
      placement: 'top-end'
    },
    {
      title: 'Bottom start',
      placement: 'bottom-start'
    },
    {
      title: 'Bottom',
      placement: 'bottom'
    },
    {
      title: 'Bottom end',
      placement: 'bottom-end'
    },
    {
      title: 'Left start',
      placement: 'left-start'
    },
    {
      title: 'Left',
      placement: 'left'
    },
    {
      title: 'Left end',
      placement: 'left-end'
    },
    {
      title: 'Right start',
      placement: 'right-start'
    },
    {
      title: 'Right',
      placement: 'right'
    },
    {
      title: 'Right end',
      placement: 'right-end'
    }
  ] as const;

  return (
    <div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
      {popoverPlacements.map((placement) => (
        <Popover 
          key={placement.placement} 
          title={placement.title} 
          placement={placement.placement}
          width={260}
        >
          {content}
        </Popover>
      ))}
    </div>
  );
}

Controlled

Preview
Dark
import { useState } from 'react';
import Calendar from '@/components/ui/Calendar';
import Popover from '@/components/ui/Popover';

export default function ControlledDemo() {
  const [open, setOpen] = useState(false);
  const [date, setDate] = useState<Date | null>(null);

  const onCalendarChange = (value: Date | null) => {
    setDate(value);
    setOpen(false);
  };

  return (
    <div className="flex justify-center">
      <Popover
        title={date ? date.toDateString() : 'Select a date'}
        open={open}
        onOpenChange={setOpen}
        placement="bottom-start"
        width={280}
      >
        <Calendar value={date} onChange={onCalendarChange} />
      </Popover>
    </div>
  );
}

API

Popover renders a title (or a renderTrigger element) as the trigger and floats its children in a portalled panel. Open state is uncontrolled by default, or controlled with open and onOpenChange.

PropDescriptionTypeDefault
titleLabel for the default trigger. Rendered inside the built-in button when renderTrigger is not set.string | ReactNode-
renderTriggerCustom element used as the trigger, replacing the default button.ReactNode-
childrenContent rendered inside the floating panel.ReactNode-
triggerHow the panel opens.'click' | 'hover''click'
placementSide and alignment the panel opens from, relative to the trigger.Placement'bottom'
openControls the open state. Pair with onOpenChange.boolean-
onOpenChangeCalled when the open state should change.(open: boolean) => void-
widthWidth of the panel body.number | string-
modalTraps focus within the panel while open.booleanfalse
classNameClass names applied to the panel body.string-
...nativePropsNative div props applied to the floating panel.Omit<ComponentProps<'div'>, 'title'>-

Types

type Placement =
  | 'top'
  | 'top-start'
  | 'top-end'
  | 'bottom'
  | 'bottom-start'
  | 'bottom-end'
  | 'right'
  | 'right-start'
  | 'right-end'
  | 'left'
  | 'left-start'
  | 'left-end';