Skip to content
data-slotv1.0.0
Esc
↑↓navigate↵open⌘Jpreview
On this page

Popover

A floating panel anchored to a trigger for contextual content and controls.

@data-slot/popoverSource ↗
bun add @data-slot/popover
npm install @data-slot/popover
pnpm add @data-slot/popover

Anatomy

A trigger and its content. popover-close is optional — Escape and a click outside already close the panel.

<div data-slot="popover">
  <button data-slot="popover-trigger">Trigger</button>
  <div data-slot="popover-content">
    Content
    <button data-slot="popover-close">Close</button>
  </div>
</div>

Examples

Anchored panel

Preview
Show codeHide code
<div data-slot="popover" class="popover-root">
  <button data-slot="popover-trigger" class="popover-trigger-btn">Open Popover</button>
  <div data-slot="popover-content" data-side="bottom" data-align="center" class="popover-content" hidden>
    <div class="popover-title">Popover Panel</div>
    <p class="popover-text">
      Unlike tooltips, popovers stay open until dismissed.
      Click outside or press Escape to close.
    </p>
    <button data-slot="popover-close" class="popover-close-btn">Got it</button>
  </div>
</div>

<style>
  .popover-root { display: inline-block; }
  .popover-trigger-btn {
    padding: 0.5rem 1rem;
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    cursor: pointer;
  }
  .popover-content {
    position: fixed;
    background: var(--surface);
    border: 1px solid var(--border);
    padding: 1rem;
    width: 20rem;
    z-index: 50;
    transform-origin: var(--transform-origin, center);
    --popover-slide-x: 0px;
    --popover-slide-y: -4px;
    max-width: var(--available-width);
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  }
  .popover-content[data-side="top"] {
    --popover-slide-y: 4px;
  }
  .popover-content[data-side="bottom"] {
    --popover-slide-y: -4px;
  }
  .popover-content[data-side="left"] {
    --popover-slide-x: 4px;
    --popover-slide-y: 0px;
  }
  .popover-content[data-side="right"] {
    --popover-slide-x: -4px;
    --popover-slide-y: 0px;
  }
  .popover-content[data-open] {
    animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
  }
  .popover-content[data-closed] {
    pointer-events: none;
    animation: popover-out 120ms ease-in forwards;
  }
  @keyframes popover-in {
    from {
      opacity: 0;
      scale: 0.96;
      translate: var(--popover-slide-x) var(--popover-slide-y);
    }
    to {
      opacity: 1;
      scale: 1;
      translate: 0 0;
    }
  }
  @keyframes popover-out {
    from {
      opacity: 1;
      scale: 1;
      translate: 0 0;
    }
    to {
      opacity: 0;
      scale: 0.96;
      translate: var(--popover-slide-x) var(--popover-slide-y);
    }
  }
  .popover-title { font-weight: 700; margin-bottom: 0.5rem; }
  .popover-text { color: var(--muted); font-size: 0.875rem; margin-bottom: 0.75rem;
    line-height: 1.25rem;
  }
  .popover-close-btn {
    padding: 0.25rem 0.625rem;
    background: none;
    border: 1px solid var(--border);
    cursor: pointer;
    font-size: 0.875rem;
    line-height: 1.25rem;
  }
</style>
<div data-slot="popover" class="relative inline-block">
  <button
    data-slot="popover-trigger"
    class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
           hover:opacity-90 transition-opacity"
  >
    Open Popover
  </button>
  <div
    data-slot="popover-content"
    data-side="bottom"
    data-align="center"
    class="fixed bg-[var(--surface)] border border-[var(--border)]
           p-4 w-80 max-w-(--available-width) z-50 shadow-lg"
    hidden
  >
    <div class="font-bold mb-2 mt-0">Popover Panel</div>
    <p class="text-[var(--muted)] text-sm mb-3">
      Unlike tooltips, popovers stay open until dismissed.
      Click outside or press Escape to close.
    </p>
    <button
      data-slot="popover-close"
      class="px-2.5 py-1 bg-transparent border border-[var(--border)]
             cursor-pointer hover:bg-code-bg transition-colors"
    >
      Got it
    </button>
  </div>
</div>

/* State-based animation (works with presence lifecycle) */
[data-slot="popover-content"] {
  transform-origin: var(--transform-origin, center);
  --popover-slide-x: 0px;
  --popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="top"] {
  --popover-slide-y: 4px;
}
[data-slot="popover-content"][data-side="bottom"] {
  --popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="left"] {
  --popover-slide-x: 4px;
  --popover-slide-y: 0px;
}
[data-slot="popover-content"][data-side="right"] {
  --popover-slide-x: -4px;
  --popover-slide-y: 0px;
}
[data-slot="popover-content"][data-open] {
  animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="popover-content"][data-closed] {
  pointer-events: none;
  animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
  from {
    opacity: 0;
    scale: 0.96;
    translate: var(--popover-slide-x) var(--popover-slide-y);
  }
  to {
    opacity: 1;
    scale: 1;
    translate: 0 0;
  }
}
@keyframes popover-out {
  from {
    opacity: 1;
    scale: 1;
    translate: 0 0;
  }
  to {
    opacity: 0;
    scale: 0.96;
    translate: var(--popover-slide-x) var(--popover-slide-y);
  }
}

Position above the trigger

data-side="top" opens the panel above the trigger when there is room.

Preview
Show codeHide code
<div data-slot="popover" class="popover-root">
  <button data-slot="popover-trigger" class="popover-trigger-btn">Open Popover</button>
  <div data-slot="popover-content" data-side="top" data-align="center" class="popover-content" hidden>
    <div class="popover-title">Popover Panel</div>
    <p class="popover-text">
      Unlike tooltips, popovers stay open until dismissed.
      Click outside or press Escape to close.
    </p>
    <button data-slot="popover-close" class="popover-close-btn">Got it</button>
  </div>
</div>

<style>
  .popover-root { display: inline-block; }
  .popover-trigger-btn {
    padding: 0.5rem 1rem;
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    cursor: pointer;
  }
  .popover-content {
    position: fixed;
    background: var(--surface);
    border: 1px solid var(--border);
    padding: 1rem;
    width: 20rem;
    z-index: 50;
    transform-origin: var(--transform-origin, center);
    --popover-slide-x: 0px;
    --popover-slide-y: -4px;
    max-width: var(--available-width);
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  }
  .popover-content[data-side="top"] {
    --popover-slide-y: 4px;
  }
  .popover-content[data-side="bottom"] {
    --popover-slide-y: -4px;
  }
  .popover-content[data-side="left"] {
    --popover-slide-x: 4px;
    --popover-slide-y: 0px;
  }
  .popover-content[data-side="right"] {
    --popover-slide-x: -4px;
    --popover-slide-y: 0px;
  }
  .popover-content[data-open] {
    animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
  }
  .popover-content[data-closed] {
    pointer-events: none;
    animation: popover-out 120ms ease-in forwards;
  }
  @keyframes popover-in {
    from {
      opacity: 0;
      scale: 0.96;
      translate: var(--popover-slide-x) var(--popover-slide-y);
    }
    to {
      opacity: 1;
      scale: 1;
      translate: 0 0;
    }
  }
  @keyframes popover-out {
    from {
      opacity: 1;
      scale: 1;
      translate: 0 0;
    }
    to {
      opacity: 0;
      scale: 0.96;
      translate: var(--popover-slide-x) var(--popover-slide-y);
    }
  }
  .popover-title { font-weight: 700; margin-bottom: 0.5rem; }
  .popover-text { color: var(--muted); font-size: 0.875rem; margin-bottom: 0.75rem;
    line-height: 1.25rem;
  }
  .popover-close-btn {
    padding: 0.25rem 0.625rem;
    background: none;
    border: 1px solid var(--border);
    cursor: pointer;
    font-size: 0.875rem;
    line-height: 1.25rem;
  }
</style>
<div data-slot="popover" class="relative inline-block">
  <button
    data-slot="popover-trigger"
    class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
           hover:opacity-90 transition-opacity"
  >
    Open Popover
  </button>
  <div
    data-slot="popover-content" data-side="top"
   
    data-align="center"
    class="fixed bg-[var(--surface)] border border-[var(--border)]
           p-4 w-80 max-w-(--available-width) z-50 shadow-lg"
    hidden
  >
    <div class="font-bold mb-2 mt-0">Popover Panel</div>
    <p class="text-[var(--muted)] text-sm mb-3">
      Unlike tooltips, popovers stay open until dismissed.
      Click outside or press Escape to close.
    </p>
    <button
      data-slot="popover-close"
      class="px-2.5 py-1 bg-transparent border border-[var(--border)]
             cursor-pointer hover:bg-code-bg transition-colors"
    >
      Got it
    </button>
  </div>
</div>

/* State-based animation (works with presence lifecycle) */
[data-slot="popover-content"] {
  transform-origin: var(--transform-origin, center);
  --popover-slide-x: 0px;
  --popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="top"] {
  --popover-slide-y: 4px;
}
[data-slot="popover-content"][data-side="bottom"] {
  --popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="left"] {
  --popover-slide-x: 4px;
  --popover-slide-y: 0px;
}
[data-slot="popover-content"][data-side="right"] {
  --popover-slide-x: -4px;
  --popover-slide-y: 0px;
}
[data-slot="popover-content"][data-open] {
  animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="popover-content"][data-closed] {
  pointer-events: none;
  animation: popover-out 120ms ease-in forwards;
}
@keyframes popover-in {
  from {
    opacity: 0;
    scale: 0.96;
    translate: var(--popover-slide-x) var(--popover-slide-y);
  }
  to {
    opacity: 1;
    scale: 1;
    translate: 0 0;
  }
}
@keyframes popover-out {
  from {
    opacity: 1;
    scale: 1;
    translate: 0 0;
  }
  to {
    opacity: 0;
    scale: 0.96;
    translate: var(--popover-slide-x) var(--popover-slide-y);
  }
}

API reference

Initialization

create(scope?)

Auto-discover and bind all popover instances in a scope (defaults to document).

import { create } from "@data-slot/popover";

const controllers = create(); // Returns PopoverController[]

createPopover(root, options?)

Create a controller for a specific element.

import { createPopover } from "@data-slot/popover";

const popover = createPopover(element, {
  defaultOpen: false,
  side: "bottom",
  align: "center",
  sideOffset: 4,
  alignOffset: 0,
  avoidCollisions: true,
  collisionPadding: 8,
  portal: true,
  closeOnClickOutside: true,
  closeOnEscape: true,
  onOpenChange: (open) => console.log(open),
});

Slots

Runtime Slots

  • popover - Root element that manages the open state.
  • popover-trigger - Required button that toggles the popover and anchors its position.
  • popover-content - Required floating panel containing the popover’s content.
  • popover-close - Optional button inside the content that closes the popover.
  • popover-positioner - Optional authored positioning wrapper around the content, reused instead of a generated wrapper.
  • popover-portal - Optional authored portal wrapper that can contain the positioner and content.

Markup

<div data-slot="popover">
  <button data-slot="popover-trigger">Trigger</button>
  <div data-slot="popover-content">
    Content
    <button data-slot="popover-close">Close</button>
  </div>
</div>

Composed Portal Markup (Optional)

<div data-slot="popover">
  <button data-slot="popover-trigger">Trigger</button>
  <div data-slot="popover-portal">
    <div data-slot="popover-positioner">
      <div data-slot="popover-content">Content</div>
    </div>
  </div>
</div>

Data Attributes

Options can also be set via data attributes. JS options take precedence over data attributes.

Placement attributes (data-side, data-align, data-side-offset, data-align-offset, data-avoid-collisions, data-collision-padding) resolve in this order:

  1. JavaScript option
  2. popover-content
  3. popover-positioner
  4. popover root (fallback)
Attribute Type Default Description
data-default-open boolean false Initial open state
data-side string "bottom" Preferred side
data-align string "center" Preferred alignment
data-side-offset number 4 Distance from trigger (px)
data-align-offset number 0 Offset from alignment edge (px)
data-avoid-collisions boolean true Flip/shift to stay in viewport
data-collision-padding number 8 Viewport edge padding (px)
data-portal boolean true Portal content to document.body while open
data-close-on-click-outside boolean true Close when clicking outside
data-close-on-escape boolean true Close when pressing Escape

Boolean attributes: present or "true" = true, "false" = false, absent = default.

Placement can be set on root, content, or authored positioner (content takes precedence):

<div data-slot="popover-content" data-side="top" data-align="end">

data-position is still supported as a deprecated fallback alias for data-side.

<!-- Popover that stays open when clicking outside -->
<div data-slot="popover" data-close-on-click-outside="false">
  ...
</div>

Options

Option Type Default Description
defaultOpen boolean false Initial open state
side "top" | "right" | "bottom" | "left" "bottom" Preferred side relative to trigger
align "start" | "center" | "end" "center" Preferred alignment on the side axis
sideOffset number 4 Distance from trigger in pixels
alignOffset number 0 Offset from alignment edge in pixels
avoidCollisions boolean true Flip/shift to stay in viewport
collisionPadding number 8 Viewport edge padding in pixels
portal boolean true Portal content to document.body while open
position "top" | "bottom" | "left" | "right" - Deprecated alias for side
closeOnClickOutside boolean true Close when clicking outside
closeOnEscape boolean true Close when pressing Escape
onOpenChange (open: boolean) => void undefined Callback when open state changes

Controller

Method/Property Description
open() Open the popover
close() Close the popover
toggle() Toggle the popover
isOpen Current open state (readonly boolean)
destroy() Cleanup all event listeners

Controller Destruction

destroy() permanently disposes the controller and hides any open surface without emitting an additional change event. Repeated destruction is safe; methods on the old controller become no-ops. Create a new controller on the same root to rebind it.

Focus restoration already queued by a close survives destruction.

Events

Outbound Events

Listen for changes via custom events:

element.addEventListener("popover:change", (e) => {
  console.log("Popover open:", e.detail.open);
});

Inbound Events

Control the popover via events:

Event Detail Description
popover:set { open: boolean } Set open state programmatically
// Open the popover
element.dispatchEvent(
  new CustomEvent("popover:set", { detail: { open: true } })
);

// Close the popover
element.dispatchEvent(
  new CustomEvent("popover:set", { detail: { open: false } })
);
Deprecated Shapes

The following shapes are deprecated and will be removed in the next major release:

  • popover:set detail { value: boolean } (use { open: boolean })
  • position option (use side)
  • data-position attribute (use data-side)
// Deprecated: { value: boolean }
element.dispatchEvent(
  new CustomEvent("popover:set", { detail: { value: true } })
);

Use the replacements listed above.

Styling

Popover position is computed in JavaScript and applied as position: absolute + inline transform: translate3d(...). By default, content is portaled to document.body while open (document coordinates). If you provide authored popover-positioner / popover-portal slots, those are reused. Otherwise a transient popover-positioner wrapper is generated. If portal is disabled, positioning is applied directly to popover-content. The positioned element (popover-positioner, or popover-content when portal is disabled) also receives --transform-origin so popup animations can originate from the trigger anchor. Use data-open/data-closed and data-side for styling/animation. This keeps popover-content free for transform animations. Placement uses layout dimensions, so scale/zoom animations on popover-content do not require an extra inner wrapper for stable positioning.

[data-slot="popover-content"] {
  transform-origin: var(--transform-origin, center);
  --popover-slide-x: 0px;
  --popover-slide-y: -4px;
}

[data-slot="popover-content"][data-side="top"] {
  --popover-slide-y: 4px;
}
[data-slot="popover-content"][data-side="bottom"] {
  --popover-slide-y: -4px;
}
[data-slot="popover-content"][data-side="left"] {
  --popover-slide-x: 4px;
  --popover-slide-y: 0px;
}
[data-slot="popover-content"][data-side="right"] {
  --popover-slide-x: -4px;
  --popover-slide-y: 0px;
}

[data-slot="popover-content"][data-open] {
  animation: popover-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
}

[data-slot="popover-content"][data-closed] {
  pointer-events: none;
  animation: popover-out 120ms ease-in forwards;
}

@keyframes popover-in {
  from {
    opacity: 0;
    scale: 0.96;
    translate: var(--popover-slide-x) var(--popover-slide-y);
  }
  to {
    opacity: 1;
    scale: 1;
    translate: 0 0;
  }
}

@keyframes popover-out {
  from {
    opacity: 1;
    scale: 1;
    translate: 0 0;
  }
  to {
    opacity: 0;
    scale: 0.96;
    translate: var(--popover-slide-x) var(--popover-slide-y);
  }
}

With Tailwind:

<div data-slot="popover">
  <button data-slot="popover-trigger">Open</button>
  <div
    data-slot="popover-content"
    data-side="bottom"
    data-align="start"
    class="absolute bg-white shadow-lg p-4"
  >
    Content
  </div>
</div>

Use Tailwind for layout/colors and keep the state selectors from the CSS snippet above for fade/zoom animation.

Keyboard Navigation

Key Action
Enter / Space Toggle popover (on trigger)
Escape Close popover and return focus to trigger

Accessibility

The component automatically handles:

  • aria-haspopup="dialog" on trigger
  • aria-controls linking trigger to content
  • aria-expanded state on trigger
  • Unique ID generation for content