Beta, expect API change, and bugs. Hit one? Tell us.

Skip to content

Popover

A floating anchored panel.

<script setup lang="ts">
import { Popover } from '@shardsui/vue/popover'
</script>

<template>
  <Popover.Root>
    <Popover.Trigger
      class="flex size-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
      aria-label="Activity"
    >
      <svg viewBox="0 0 24 24" fill="none" class="size-4" aria-hidden="true">
        <path
          d="M17.8186 7.96008C18.9803 9.06824 19.4816 10.4992 19.6695 11.9928C20.8623 21.3865 8.45545 24.7553 4.84302 16.7772C3.33975 13.447 4.88844 10.2793 7.31572 7.94562C8.99792 6.33156 10.6416 5.03914 11.3646 2.5C14.5716 4.58333 15.0895 8.75 15.0895 10.8333C15.0895 10.8333 17.2824 8.71744 17.8186 7.96008Z"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linejoin="round"
        />
      </svg>
    </Popover.Trigger>
    <Popover.Portal>
      <Popover.Positioner :side-offset="8">
        <Popover.Popup
          class="flex max-w-72 origin-(--transform-origin) flex-col gap-1 rounded-lg bg-gray-50 p-3 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Popover.Arrow
            class="data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
          >
            <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
              <path
                d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                class="fill-gray-50"
              />
              <path
                d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                class="fill-gray-200"
              />
              <path
                d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
              />
            </svg>
          </Popover.Arrow>
          <Popover.Title class="text-sm font-semibold">Activity</Popover.Title>
          <Popover.Description class="text-sm text-gray-600">
            12 days active this month.
          </Popover.Description>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

Anatomy

<script setup>
import { Popover } from '@shardsui/vue/popover'
</script>

<template>
  <Popover.Root>
    <Popover.Trigger />
    <Popover.Portal>
      <Popover.Backdrop />
      <Popover.Positioner>
        <Popover.Popup>
          <Popover.Arrow />
          <Popover.Viewport>
            <Popover.Title />
            <Popover.Description />
            <Popover.Close />
          </Popover.Viewport>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

Examples

Opening on hover

Set openOnHover on the trigger to open the popover on hover as well as on click. Tune the timing with delay and closeDelay, both in milliseconds.

<script setup lang="ts">
import { Popover } from '@shardsui/vue/popover'
</script>

<template>
  <Popover.Root>
    <Popover.Trigger
      open-on-hover
      class="flex size-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
      aria-label="Activity"
    >
      <svg viewBox="0 0 24 24" fill="none" class="size-4" aria-hidden="true">
        <path
          d="M17.8186 7.96008C18.9803 9.06824 19.4816 10.4992 19.6695 11.9928C20.8623 21.3865 8.45545 24.7553 4.84302 16.7772C3.33975 13.447 4.88844 10.2793 7.31572 7.94562C8.99792 6.33156 10.6416 5.03914 11.3646 2.5C14.5716 4.58333 15.0895 8.75 15.0895 10.8333C15.0895 10.8333 17.2824 8.71744 17.8186 7.96008Z"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linejoin="round"
        />
      </svg>
    </Popover.Trigger>
    <Popover.Portal>
      <Popover.Positioner :side-offset="8">
        <Popover.Popup
          class="flex max-w-72 origin-(--transform-origin) flex-col gap-1 rounded-lg bg-gray-50 p-3 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Popover.Arrow
            class="data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
          >
            <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
              <path
                d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                class="fill-gray-50"
              />
              <path
                d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                class="fill-gray-200"
              />
              <path
                d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
              />
            </svg>
          </Popover.Arrow>
          <Popover.Title class="text-sm font-semibold">Activity</Popover.Title>
          <Popover.Description class="text-sm text-gray-600">
            12 days active this month.
          </Popover.Description>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

Detached triggers

By default the trigger sits inside <Popover.Root>, right beside the content it opens, as at the top of this page.

When the two can't live together in the markup, detach them: render <Popover.Trigger> wherever it makes sense and connect it to the root with a shared handle from Popover.createHandle().

<script setup>
const demoPopover = Popover.createHandle()
</script>

<template>
  <!-- [!code word::handle="demoPopover"] -->
  <Popover.Trigger :handle="demoPopover">Trigger</Popover.Trigger>

  <!-- [!code word::handle="demoPopover"] -->
  <Popover.Root :handle="demoPopover">...</Popover.Root>
</template>
<script setup lang="ts">
import { Popover } from '@shardsui/vue/popover'

const demoPopover = Popover.createHandle()
</script>

<template>
  <Popover.Trigger
    :handle="demoPopover"
    class="flex size-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
    aria-label="Activity"
  >
    <svg viewBox="0 0 24 24" fill="none" class="size-4" aria-hidden="true">
      <path
        d="M17.8186 7.96008C18.9803 9.06824 19.4816 10.4992 19.6695 11.9928C20.8623 21.3865 8.45545 24.7553 4.84302 16.7772C3.33975 13.447 4.88844 10.2793 7.31572 7.94562C8.99792 6.33156 10.6416 5.03914 11.3646 2.5C14.5716 4.58333 15.0895 8.75 15.0895 10.8333C15.0895 10.8333 17.2824 8.71744 17.8186 7.96008Z"
        stroke="currentColor"
        stroke-width="1.5"
        stroke-linejoin="round"
      />
    </svg>
  </Popover.Trigger>

  <Popover.Root :handle="demoPopover">
    <Popover.Portal>
      <Popover.Positioner :side-offset="8">
        <Popover.Popup
          class="flex max-w-72 origin-(--transform-origin) flex-col gap-1 rounded-lg bg-gray-50 p-3 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Popover.Arrow
            class="data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
          >
            <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
              <path
                d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                class="fill-gray-50"
              />
              <path
                d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                class="fill-gray-200"
              />
              <path
                d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
              />
            </svg>
          </Popover.Arrow>
          <Popover.Title class="text-sm font-semibold">Activity</Popover.Title>
          <Popover.Description class="text-sm text-gray-600">
            12 days active this month.
          </Popover.Description>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

Multiple triggers

One popover can answer to several triggers: drop multiple <Popover.Trigger> elements inside a single <Popover.Root>, or give the same handle to any number of detached triggers.

<template>
  <Popover.Root>
    <Popover.Trigger>Trigger 1</Popover.Trigger>
    <Popover.Trigger>Trigger 2</Popover.Trigger>
    ...
  </Popover.Root>
</template>
<script setup>
const demoPopover = Popover.createHandle()
</script>

<template>
  <Popover.Trigger :handle="demoPopover">Trigger 1</Popover.Trigger>

  <Popover.Trigger :handle="demoPopover">Trigger 2</Popover.Trigger>

  <Popover.Root :handle="demoPopover">...</Popover.Root>
</template>

When triggers share a popover, each one can hand the root its own data through the payload prop; read it back from the default slot of <Popover.Root> to tailor what the panel shows. Pass a type argument to Popover.createHandle() to type the payload:

<script setup lang="ts">
const demoPopover = Popover.createHandle<{ text: string }>() 
</script>

<template>
  <Popover.Trigger :handle="demoPopover" :payload="{ text: 'Trigger 1' }"
    >Trigger 1</Popover.Trigger
  >

  <Popover.Trigger :handle="demoPopover" :payload="{ text: 'Trigger 2' }"
    >Trigger 2</Popover.Trigger
  >

  <Popover.Root v-slot="{ payload }" :handle="demoPopover">
    <Popover.Portal>
      <Popover.Positioner :side-offset="8">
        <Popover.Popup>
          <Popover.Title>Popover</Popover.Title>
          <Popover.Description v-if="payload !== undefined">
            This has been opened by {{ payload.text }}
          </Popover.Description>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

Controlled mode with multiple triggers

To drive the popover from your own state, bind v-model:open on <Popover.Root>. With several triggers, give each one an id and add v-model:trigger-id to <Popover.Root>: each trigger publishes its own id when it opens the popover, and setting triggerId yourself anchors the popover to that trigger.

<script setup lang="ts">
import { h, shallowRef } from 'vue'
import { Popover } from '@shardsui/vue/popover'

const icon = { viewBox: '0 0 24 24', fill: 'none', class: 'size-4', 'aria-hidden': 'true' }
const stroke = { stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-linejoin': 'round' }

const BookIcon = () =>
  h('svg', icon, [
    h('path', {
      d: 'M19.25 12V13.75C19.25 15.4069 17.9069 16.75 16.25 16.75H7C5.75736 16.75 4.75 17.7574 4.75 19C4.75 20.2426 5.75736 21.25 7 21.25H10M8.75 7H15.25M8.75 11H12.25M6.75 2.75H16.25C17.9069 2.75 19.25 4.09315 19.25 5.75V18.25C19.25 19.9069 17.9069 21.25 16.25 21.25H6.75C5.64543 21.25 4.75 20.3546 4.75 19.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75Z',
      ...stroke,
      'stroke-linecap': 'round'
    })
  ])

const ProgressIcon = () =>
  h('svg', icon, [
    h('circle', { cx: '12', cy: '12', r: '9.25', stroke: 'currentColor', 'stroke-width': '1.5' }),
    h('path', {
      d: 'M12 18.75C15.7279 18.75 18.75 15.7279 18.75 12C18.75 8.27208 15.7279 5.25 12 5.25V12H5.25C5.25 15.7279 8.27208 18.75 12 18.75Z',
      fill: 'currentColor'
    })
  ])

const PeopleIcon = () =>
  h('svg', icon, [
    h('path', {
      d: 'M15.75 6.5C15.75 8.57107 14.0711 10.25 12 10.25C9.92893 10.25 8.25 8.57107 8.25 6.5C8.25 4.42893 9.92893 2.75 12 2.75C14.0711 2.75 15.75 4.42893 15.75 6.5Z',
      ...stroke
    }),
    h('path', {
      d: 'M11.9997 13.25C9.02123 13.25 6.67402 14.8039 5.43304 17.1121C4.59593 18.6691 6.02717 20.25 7.79494 20.25H16.2044C17.9722 20.25 19.4034 18.6691 18.5663 17.1121C17.3254 14.8039 14.9781 13.25 11.9997 13.25Z',
      ...stroke
    })
  ])

const triggers = [
  { id: 'details', label: 'Details', icon: BookIcon },
  { id: 'activity', label: 'Activity', icon: ProgressIcon },
  { id: 'team', label: 'Team', icon: PeopleIcon }
]

const demoPopover = Popover.createHandle()

const open = shallowRef(false)
const triggerId = shallowRef<string | null>(null)

function openActivity() {
  triggerId.value = 'activity'
  open.value = true
}
</script>

<template>
  <div class="flex flex-wrap justify-center gap-2">
    <Popover.Trigger
      v-for="trigger in triggers"
      :key="trigger.id"
      :handle="demoPopover"
      :id="trigger.id"
      class="flex size-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
      :aria-label="trigger.label"
    >
      <component :is="trigger.icon" />
    </Popover.Trigger>

    <button
      type="button"
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
      @click="openActivity"
    >
      Open Activity
    </button>
  </div>

  <Popover.Root v-model:open="open" v-model:trigger-id="triggerId" :handle="demoPopover">
    <Popover.Portal>
      <Popover.Positioner
        class="h-(--positioner-height) w-(--positioner-width) max-w-(--available-width)"
        :side-offset="8"
      >
        <Popover.Popup
          class="relative flex h-(--popup-height,auto) w-(--popup-width,auto) max-w-72 origin-(--transform-origin) flex-col gap-1 rounded-lg bg-gray-50 p-3 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 ease-out data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Popover.Arrow
            class="data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
          >
            <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
              <path
                d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                class="fill-gray-50"
              />
              <path
                d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                class="fill-gray-200"
              />
              <path
                d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
              />
            </svg>
          </Popover.Arrow>
          <template v-if="triggerId === 'details'">
            <Popover.Title class="m-0 text-sm font-semibold">Details</Popover.Title>
            <Popover.Description class="m-0 text-sm text-gray-600">
              Three sections with twelve items in total.
            </Popover.Description>
          </template>
          <template v-else-if="triggerId === 'team'">
            <Popover.Title class="m-0 text-sm font-semibold">Team</Popover.Title>
            <div class="mt-1 flex flex-col gap-1 text-sm">
              <a href="/" class="text-gray-900 no-underline hover:underline">Milton Glaser</a>
              <a href="/" class="text-gray-900 no-underline hover:underline">Paula Scher</a>
              <a href="/" class="text-gray-900 no-underline hover:underline">Stefan Sagmeister</a>
            </div>
          </template>
          <template v-else>
            <Popover.Title class="m-0 text-sm font-semibold">Activity</Popover.Title>
            <Popover.Description class="m-0 text-sm text-gray-600">
              Seven of twelve items done this week.
            </Popover.Description>
          </template>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

Animating the Popover

When one popover serves several triggers, it can glide from one to the next instead of snapping. Its position, its size, and its contents animate independently.

Position and Size

Position lives on the Positioner, so transition its left, right, top, and bottom. Size lives on the Popup, so transition its width and height.

Content

The content itself can cross-fade when the active trigger changes. Wrap it in <Popover.Viewport>, which notices the switch and exposes a data-activation-direction attribute — a space-separated horizontal and vertical pair such as right down — so the animation can lean toward the new trigger. Match a single token with the ~= attribute selector, such as [data-activation-direction~='right'].

During a transition the viewport keeps both the incoming and outgoing content mounted, each in its own wrapper:

  • data-current: the entering content, or the sole content when nothing is transitioning.
  • data-previous: the leaving content during a transition.
<script setup lang="ts">
import { h, type Component } from 'vue'
import { Popover } from '@shardsui/vue/popover'

const icon = { viewBox: '0 0 24 24', fill: 'none', class: 'size-4', 'aria-hidden': 'true' }
const stroke = { stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-linejoin': 'round' }

const BookIcon = () =>
  h('svg', icon, [
    h('path', {
      d: 'M19.25 12V13.75C19.25 15.4069 17.9069 16.75 16.25 16.75H7C5.75736 16.75 4.75 17.7574 4.75 19C4.75 20.2426 5.75736 21.25 7 21.25H10M8.75 7H15.25M8.75 11H12.25M6.75 2.75H16.25C17.9069 2.75 19.25 4.09315 19.25 5.75V18.25C19.25 19.9069 17.9069 21.25 16.25 21.25H6.75C5.64543 21.25 4.75 20.3546 4.75 19.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75Z',
      ...stroke,
      'stroke-linecap': 'round'
    })
  ])

const ProgressIcon = () =>
  h('svg', icon, [
    h('circle', { cx: '12', cy: '12', r: '9.25', stroke: 'currentColor', 'stroke-width': '1.5' }),
    h('path', {
      d: 'M12 18.75C15.7279 18.75 18.75 15.7279 18.75 12C18.75 8.27208 15.7279 5.25 12 5.25V12H5.25C5.25 15.7279 8.27208 18.75 12 18.75Z',
      fill: 'currentColor'
    })
  ])

const PeopleIcon = () =>
  h('svg', icon, [
    h('path', {
      d: 'M15.75 6.5C15.75 8.57107 14.0711 10.25 12 10.25C9.92893 10.25 8.25 8.57107 8.25 6.5C8.25 4.42893 9.92893 2.75 12 2.75C14.0711 2.75 15.75 4.42893 15.75 6.5Z',
      ...stroke
    }),
    h('path', {
      d: 'M11.9997 13.25C9.02123 13.25 6.67402 14.8039 5.43304 17.1121C4.59593 18.6691 6.02717 20.25 7.79494 20.25H16.2044C17.9722 20.25 19.4034 18.6691 18.5663 17.1121C17.3254 14.8039 14.9781 13.25 11.9997 13.25Z',
      ...stroke
    })
  ])

const DetailsPanel = () => [
  h(Popover.Title, { class: 'm-0 text-sm font-semibold' }, () => 'Details'),
  h(
    Popover.Description,
    { class: 'm-0 text-sm text-gray-600' },
    () => 'Three sections with twelve items in total.'
  )
]

const ActivityPanel = () => [
  h(Popover.Title, { class: 'm-0 text-sm font-semibold' }, () => 'Activity'),
  h(
    Popover.Description,
    { class: 'm-0 text-sm text-gray-600' },
    () => 'Seven of twelve items done this week.'
  )
]

const TeamPanel = () => [
  h(Popover.Title, { class: 'm-0 text-sm font-semibold' }, () => 'Team'),
  h('div', { class: 'mt-1 flex flex-col gap-1 text-sm' }, [
    h('a', { href: '/', class: 'text-gray-900 no-underline hover:underline' }, 'Milton Glaser'),
    h('a', { href: '/', class: 'text-gray-900 no-underline hover:underline' }, 'Paula Scher'),
    h('a', { href: '/', class: 'text-gray-900 no-underline hover:underline' }, 'Stefan Sagmeister')
  ])
]

const demoPopover = Popover.createHandle<Component>()

const triggers = [
  { label: 'Details', icon: BookIcon, payload: DetailsPanel },
  { label: 'Activity', icon: ProgressIcon, payload: ActivityPanel },
  { label: 'Team', icon: PeopleIcon, payload: TeamPanel }
]
</script>

<template>
  <div class="flex gap-2">
    <Popover.Trigger
      v-for="trigger in triggers"
      :key="trigger.label"
      :handle="demoPopover"
      :payload="trigger.payload"
      class="box-border flex size-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100 data-popup-open:bg-gray-100"
      :aria-label="trigger.label"
    >
      <component :is="trigger.icon" />
    </Popover.Trigger>
  </div>

  <Popover.Root v-slot="{ payload }" :handle="demoPopover">
    <Popover.Portal>
      <Popover.Positioner
        :side-offset="8"
        class="h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] duration-350 ease-out-quint data-instant:transition-none"
      >
        <Popover.Popup
          class="relative h-(--popup-height,auto) w-(--popup-width,auto) max-w-72 origin-(--transform-origin) rounded-lg bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[width,height,opacity,scale] duration-350 ease-out-quint data-ending-style:scale-95 data-ending-style:opacity-0 data-instant:transition-none data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Popover.Arrow
            class="flex transition-[left] duration-350 ease-out-quint data-[side=bottom]:-top-2 data-[side=left]:-right-3.25 data-[side=left]:rotate-90 data-[side=right]:-left-3.25 data-[side=right]:-rotate-90 data-[side=top]:-bottom-2 data-[side=top]:rotate-180"
          >
            <svg width="20" height="10" viewBox="0 0 20 10" fill="none">
              <path
                d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
                class="fill-gray-50"
              />
              <path
                d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
                class="fill-gray-200"
              />
              <path
                d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
              />
            </svg>
          </Popover.Arrow>

          <Popover.Viewport
            class="relative size-full overflow-clip p-3 **:data-current:w-[calc(var(--popup-width)-1.5rem)] **:data-current:translate-x-0 **:data-current:opacity-100 **:data-current:transition-[translate,opacity] **:data-current:duration-[350ms,175ms] **:data-current:ease-out-quint **:data-previous:w-[calc(var(--popup-width)-1.5rem)] **:data-previous:translate-x-0 **:data-previous:opacity-100 **:data-previous:transition-[translate,opacity] **:data-previous:duration-[350ms,175ms] **:data-previous:ease-out-quint data-[activation-direction~='left']:[&_[data-current][data-starting-style]]:-translate-x-1/2 data-[activation-direction~='left']:[&_[data-current][data-starting-style]]:opacity-0 data-[activation-direction~='right']:[&_[data-current][data-starting-style]]:translate-x-1/2 data-[activation-direction~='right']:[&_[data-current][data-starting-style]]:opacity-0 data-[activation-direction~='left']:[&_[data-previous][data-ending-style]]:translate-x-1/2 data-[activation-direction~='left']:[&_[data-previous][data-ending-style]]:opacity-0 data-[activation-direction~='right']:[&_[data-previous][data-ending-style]]:-translate-x-1/2 data-[activation-direction~='right']:[&_[data-previous][data-ending-style]]:opacity-0"
          >
            <component :is="payload" v-if="payload" />
          </Popover.Viewport>
        </Popover.Popup>
      </Popover.Positioner>
    </Popover.Portal>
  </Popover.Root>
</template>

API reference

Root

Groups all parts of the popover. Doesn't render its own HTML element.

PropTypeDefault

Trigger

A button that opens the popover. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent while the popover is open from this trigger.
data-pressedPresent while the popover is open from this trigger, opened by a press rather than hover.
data-disabledPresent when the trigger is disabled.

Backdrop

An overlay displayed beneath the popup. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popover is open.
data-closedPresent when the popover is closed.
data-starting-stylePresent when the backdrop is animating in.
data-ending-stylePresent when the backdrop is animating out.

Portal

A portal that moves the popup out to <body>, clear of ancestor clipping and stacking. Renders a <div> element.

PropTypeDefault

Positioner

Positions the popover against the trigger. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-anchor-hiddenPresent when the anchor is hidden.
data-instantPresent when animations should be instant.
CSS VariableDescription
--available-widthAvailable width between the anchor and the viewport edge.
--available-heightAvailable height between the anchor and the viewport edge.
--anchor-widthWidth of the anchor element.
--anchor-heightHeight of the anchor element.
--transform-originTransform origin for scale animations.
--positioner-widthThe positioner element's width. Set while a <Popover.Viewport> morphs content.
--positioner-heightThe positioner element's height. Set while a <Popover.Viewport> morphs content.

Popup

A container for the popover contents. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popover is open.
data-closedPresent when the popover is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-starting-stylePresent when the popover is animating in.
data-ending-stylePresent when the popover is animating out.
data-instantPresent when animations should be instant.
CSS VariableDescription
--popup-widthThe popup element's width. Set while a <Popover.Viewport> morphs content.
--popup-heightThe popup element's height. Set while a <Popover.Viewport> morphs content.

Arrow

Displays an element positioned against the anchor. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-uncenteredPresent when the arrow cannot be centered.

Title

A heading that labels the popover. Renders an <h2> element.

PropTypeDefault

Description

A paragraph with additional information about the popover. Renders a <p> element.

PropTypeDefault

Close

A button that closes the popover. Renders a <button> element.

PropTypeDefault

Viewport

A viewport for displaying content transitions. This component is only required if one popup can be opened by multiple triggers, its content changes based on the trigger, and switching between them is animated. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-activation-directionDirection the popup was activated from. A horizontal and a vertical token separated by a space, e.g. right down; either can be empty, so match one with [data-activation-direction~='right'].
data-transitioningPresent while a content transition is in progress.
data-currentApplied to the wrapper of the current content.
data-previousApplied to the wrapper of the outgoing content, present only during a transition.
data-instantPresent when animations should be instant. Value is 'click', 'dismiss', 'focus', or 'trigger-change'.
data-starting-styleApplied to the data-current wrapper while the incoming content is animating in.
data-ending-styleApplied to the data-previous wrapper while the outgoing content is animating out.
CSS VariableDescription
--popup-widthWidth of the outgoing popup content, set on the data-previous container. Use it to freeze the popup size while the content morphs.
--popup-heightHeight of the outgoing popup content, set on the data-previous container.

When using the Viewport, set width: var(--positioner-width) and height: var(--positioner-height) on the Positioner so its box is frozen to the measured size during the transition; otherwise content-driven resizing can make the popup thrash or flip to another side.

Handle

Connects a <Popover.Root> with detached <Popover.Trigger> components, and controls the popover imperatively. Pass a type argument to type the payload.

const popover = Popover.createHandle<Payload>()
MemberType