Skip to main content
Nexus Design System
GitHub

Popover

Popover opens a floating panel next to a trigger. Use it for short forms and extra controls. For a hover hint, use Tooltip, and for a list of actions, use DropdownMenu.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/nxds/components/popover/popover';

export default function PopoverDefault() {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">Open popover</Button>
      </PopoverTrigger>
      <PopoverContent>
        <p className="nx:typography-body-default nx:text-muted-foreground">
          Place content for the popover here.
        </p>
      </PopoverContent>
    </Popover>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add popover

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from '@/components/nxds/components/popover/popover';

Composition

Popover
├── PopoverTrigger
├── PopoverContent
└── PopoverAnchor

Examples

With form

Put a small form with labelled inputs inside the popover.

'use client';

import * as React from 'react';

import { Button } from '@/components/nxds/components/button/button';
import { Input } from '@/components/nxds/components/input/input';
import { Label } from '@/components/nxds/components/label/label';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/nxds/components/popover/popover';

export default function PopoverWithForm() {
  const widthId = React.useId();
  const maxWidthId = React.useId();
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">Set dimensions</Button>
      </PopoverTrigger>
      <PopoverContent className="nx:w-80">
        <div className="nx:flex nx:flex-col nx:gap-4">
          <div className="nx:flex nx:flex-col nx:gap-1.5">
            <h4 className="nx:typography-label-default nx:leading-none">
              Dimensions
            </h4>
            <p className="nx:typography-body-default nx:text-muted-foreground">
              Set the dimensions for the layer.
            </p>
          </div>
          <div className="nx:flex nx:flex-col nx:gap-2">
            <div className="nx:grid nx:grid-cols-3 nx:items-center nx:gap-4">
              <Label htmlFor={widthId}>Width</Label>
              <Input
                id={widthId}
                defaultValue="100%"
                className="nx:col-span-2"
              />
            </div>
            <div className="nx:grid nx:grid-cols-3 nx:items-center nx:gap-4">
              <Label htmlFor={maxWidthId}>Max. width</Label>
              <Input
                id={maxWidthId}
                defaultValue="300px"
                className="nx:col-span-2"
              />
            </div>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}

This example also needs: Input, Label.

Placements

Set side and align on PopoverContent to place it around the trigger.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/nxds/components/popover/popover';

export default function PopoverPlacements() {
  return (
    <div className="nx:flex nx:flex-wrap nx:gap-4">
      {(['top', 'right', 'bottom', 'left'] as const).map((side) => (
        <Popover key={side}>
          <PopoverTrigger asChild>
            <Button variant="outline">side: {side}</Button>
          </PopoverTrigger>
          <PopoverContent side={side}>
            <p className="nx:typography-body-default">Opens on {side}.</p>
          </PopoverContent>
        </Popover>
      ))}
      {(['start', 'center', 'end'] as const).map((align) => (
        <Popover key={align}>
          <PopoverTrigger asChild>
            <Button variant="outline">align: {align}</Button>
          </PopoverTrigger>
          <PopoverContent align={align}>
            <p className="nx:typography-body-default">Aligned {align}.</p>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  );
}

With anchor

Wrap another element in PopoverAnchor to position the popover against it instead of the trigger.

The popover positions against this anchor box.
'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from '@/components/nxds/components/popover/popover';

export default function PopoverWithAnchor() {
  return (
    <Popover>
      <PopoverAnchor asChild>
        <div className="nx:rounded-md nx:border-default nx:border-border-default nx:bg-muted nx:p-container nx:typography-label-default nx:text-muted-foreground">
          The popover positions against this anchor box.
        </div>
      </PopoverAnchor>
      <div className="nx:mt-4">
        <PopoverTrigger asChild>
          <Button variant="outline">Toggle popover</Button>
        </PopoverTrigger>
      </div>
      <PopoverContent>
        <p className="nx:typography-body-default">
          Positioned relative to the anchor box, not the trigger button.
        </p>
      </PopoverContent>
    </Popover>
  );
}

Props

Popover

Popover adds no props of its own; it accepts the props of the element or primitive it wraps.

PopoverAnchor

PopoverAnchor adds no props of its own; it accepts the props of the element or primitive it wraps.

PopoverContent

PopoverContent adds no props of its own; it accepts the props of the element or primitive it wraps.

PopoverTrigger

PopoverTrigger adds no props of its own; it accepts the props of the element or primitive it wraps.

Popover opens a floating panel next to a trigger. Use it for short forms and extra controls. For a hover hint, use Tooltip, and for a list of actions, use DropdownMenu.

<script setup lang="ts">
import { Button } from '@/components/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';
</script>

<template>
  <Popover>
    <PopoverTrigger as-child>
      <Button variant="outline">Open popover</Button>
    </PopoverTrigger>
    <PopoverContent>
      <p class="nx:typography-body-default nx:text-muted-foreground">
        Place content for the popover here.
      </p>
    </PopoverContent>
  </Popover>
</template>

This example also needs: Button.

Installation

  1. Install the following dependencies:

    npm install @vueuse/core@^14.1.0 class-variance-authority@^0.7.1 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/overlay-layout/overlay-layout.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    /**
     * Popover-family surfaces share the translucent/blurred recipe; Tooltip uses a
     * solid carve-out because its small, brief text appears over arbitrary content.
     */
    export const popoverSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover-alpha',
      'nx:backdrop-blur-md',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const tooltipSurfaceClassName = [
      'nx:rounded-md',
      'nx:border-default',
      'nx:border-border-default',
      'nx:bg-popover',
      'nx:text-popover-foreground',
      'nx:typography-body-default',
      'nx:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverSurfaceClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:rounded-md',
      'nx:group-data-[viewport=false]/navigation-menu:border-default',
      'nx:group-data-[viewport=false]/navigation-menu:border-border-default',
      'nx:group-data-[viewport=false]/navigation-menu:bg-popover-alpha',
      'nx:group-data-[viewport=false]/navigation-menu:backdrop-blur-md',
      'nx:group-data-[viewport=false]/navigation-menu:text-popover-foreground',
      'nx:group-data-[viewport=false]/navigation-menu:shadow-lg',
    ].join(' ');
    
    export const navigationMenuInlinePopoverTransitionClassName = [
      'nx:group-data-[viewport=false]/navigation-menu:transition-[opacity,scale]',
      'nx:group-data-[viewport=false]/navigation-menu:duration-fast',
      'nx:group-data-[viewport=false]/navigation-menu:ease-move',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:scale-95',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:opacity-0',
      'nx:group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-overlay-presence-exit',
    ].join(' ');
    
    // `animate-overlay-presence-exit` is a non-visual "presence bridge": Reka's
    // Presence only waits on `animationName`, not `transitionend`, so this inert
    // animation keeps a closing overlay mounted while the opacity/scale/translate
    // TRANSITIONS below run the visible exit. It animates an unread custom property (see
    // generateMotionUtilitiesCSS in @nxds/core), never a transitioned property, so it
    // cannot override those transitions.
    const overlayPresenceExitClassName =
      'nx:data-[state=closed]:animate-overlay-presence-exit';
    
    export const overlayFloatingTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-fast',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    const overlayContentTransitionClassName = [
      'nx:transition-[opacity,scale]',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:scale-95',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayScrimTransitionClassName = [
      'nx:transition-opacity',
      'nx:duration-default',
      'nx:ease-move',
      'nx:data-[state=closed]:opacity-0',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayPanelTransitionClassName = [
      'nx:transition-[translate]',
      'nx:duration-slow',
      'nx:ease-move',
      overlayPresenceExitClassName,
    ].join(' ');
    
    export const overlayContentVariants = cva(
      [
        // Centred with margins, not a translate: a transform would make this the
        // containing block for fixed-position popups a dialog holds.
        'nx:fixed nx:inset-0 nx:z-modal nx:m-auto nx:flex nx:h-fit nx:w-full nx:max-w-lg nx:flex-col',
        'nx:max-h-[calc(100svh-2rem)] nx:overflow-hidden',
        'nx:gap-4 nx:border-default nx:border-border-default nx:bg-container nx:py-6 nx:shadow-lg',
        overlayContentTransitionClassName,
        'nx:sm:rounded-lg',
      ].join(' ')
    );
    
    export const overlayScrimVariants = cva(
      [
        'nx:fixed nx:inset-0 nx:z-modal nx:bg-overlay',
        overlayScrimTransitionClassName,
      ].join(' ')
    );
    
    export const overlayHeaderVariants = cva(
      'nx:flex nx:shrink-0 nx:flex-col nx:gap-1 nx:px-6',
      {
        variants: {
          variant: {
            default: 'nx:text-center nx:sm:text-left',
            center: 'nx:items-center nx:text-center',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export const overlayBodyClassName = [
      'nx:min-h-0',
      'nx:overflow-y-auto',
      'nx:px-6',
      'nx:focus-visible:outline-2',
      'nx:focus-visible:outline-focus-default',
      'nx:focus-visible:[outline-offset:-2px]',
    ].join(' ');
    
    export const overlayFooterVariants = cva(
      'nx:flex nx:shrink-0 nx:gap-2 nx:px-6',
      {
        variants: {
          orientation: {
            horizontal:
              'nx:flex-col nx:sm:flex-row nx:sm:items-center nx:sm:justify-end',
            vertical: 'nx:flex-col nx:items-stretch nx:*:w-full',
          },
        },
        defaultVariants: {
          orientation: 'horizontal',
        },
      }
    );
    
    export const overlayCloseButtonClassName = [
      'nx:absolute nx:right-6 nx:top-6 nx:rounded-sm nx:border-0 nx:bg-transparent nx:p-1 nx:text-muted-foreground-subtle',
      'nx:transition-control',
      'nx:hover:bg-container-hover nx:hover:text-foreground',
      'nx:focus-visible:bg-container-hover nx:focus-visible:text-foreground',
      'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
      'nx:disabled:pointer-events-none',
    ].join(' ');
    
    export type OverlayHeaderVariants = VariantProps<typeof overlayHeaderVariants>;
    
    export type OverlayVariant = NonNullable<
      VariantProps<typeof overlayHeaderVariants>['variant']
    >;
    
    export type OverlayButtonOrientation = NonNullable<
      VariantProps<typeof overlayFooterVariants>['orientation']
    >;
    
    export type OverlayLayoutContextValue = {
      variant: OverlayVariant;
      buttonOrientation: OverlayButtonOrientation;
    };
    
    export const defaultOverlayLayout: OverlayLayoutContextValue = {
      variant: 'default',
      buttonOrientation: 'horizontal',
    };
    
    export function resolveOverlayButtonOrientation(
      variant: OverlayVariant
    ): OverlayButtonOrientation {
      return variant === 'center' ? 'vertical' : 'horizontal';
    }
    
    components/popover/PopoverContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      PopoverContent,
      type PopoverContentEmits,
      type PopoverContentProps as RekaPopoverContentProps,
      PopoverPortal,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import {
      overlayFloatingTransitionClassName,
      popoverSurfaceClassName,
    } from '../overlay-layout/overlay-layout';
    
    export interface PopoverContentProps extends RekaPopoverContentProps {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<PopoverContentProps>(), {
      align: 'center',
      sideOffset: 4,
    });
    const emits = defineEmits<PopoverContentEmits>();
    
    // Reka's content emits closeAutoFocus, interactOutside and pointerDownOutside
    // twice with the same event. Pass each one on once, as Radix does.
    const emitted = new WeakMap<Event, Set<string>>();
    
    function emitOnce(name: keyof PopoverContentEmits, event: Event) {
      const names = emitted.get(event) ?? new Set<string>();
      if (names.has(name)) return;
      emitted.set(event, names.add(name));
      (emits as (name: string, event: Event) => void)(name, event);
    }
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardPropsEmits(delegated, emitOnce);
    </script>
    
    <template>
      <PopoverPortal>
        <PopoverContent
          v-bind="{ 'data-slot': 'popover-content', ...$attrs, ...forwarded }"
          :class="
            cn(
              'nx:z-popover nx:w-72 nx:p-container nx:outline-none',
              popoverSurfaceClassName,
              overlayFloatingTransitionClassName,
              props.class
            )
          "
        >
          <slot />
        </PopoverContent>
      </PopoverPortal>
    </template>
    
    components/popover/index.ts
    export {
      default as PopoverContent,
      type PopoverContentProps,
    } from './PopoverContent.vue';
    export {
      PopoverRoot as Popover,
      PopoverAnchor,
      type PopoverAnchorProps,
      type PopoverRootProps as PopoverProps,
      PopoverTrigger,
      type PopoverTriggerProps,
    } from 'reka-ui';
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/popover';

Examples

With form

Put a small form with labelled inputs inside the popover.

<script setup lang="ts">
import { useId } from 'vue';

import { Button } from '@/components/button';
import { Input } from '@/components/input';
import { Label } from '@/components/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';

const widthId = useId();
const maxWidthId = useId();
</script>

<template>
  <Popover>
    <PopoverTrigger as-child>
      <Button variant="outline">Set dimensions</Button>
    </PopoverTrigger>
    <PopoverContent class="nx:w-80">
      <div class="nx:flex nx:flex-col nx:gap-4">
        <div class="nx:flex nx:flex-col nx:gap-1.5">
          <h4 class="nx:typography-label-default nx:leading-none">
            Dimensions
          </h4>
          <p class="nx:typography-body-default nx:text-muted-foreground">
            Set the dimensions for the layer.
          </p>
        </div>
        <div class="nx:flex nx:flex-col nx:gap-2">
          <div class="nx:grid nx:grid-cols-3 nx:items-center nx:gap-4">
            <Label :for="widthId">Width</Label>
            <Input :id="widthId" default-value="100%" class="nx:col-span-2" />
          </div>
          <div class="nx:grid nx:grid-cols-3 nx:items-center nx:gap-4">
            <Label :for="maxWidthId">Max. width</Label>
            <Input
              :id="maxWidthId"
              default-value="300px"
              class="nx:col-span-2"
            />
          </div>
        </div>
      </div>
    </PopoverContent>
  </Popover>
</template>

This example also needs: Input, Label.

Placements

Set side and align on PopoverContent to place it around the trigger.

<script setup lang="ts">
import { Button } from '@/components/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';

const sides = ['top', 'right', 'bottom', 'left'] as const;
const aligns = ['start', 'center', 'end'] as const;
</script>

<template>
  <div class="nx:flex nx:flex-wrap nx:gap-4">
    <Popover v-for="side in sides" :key="side">
      <PopoverTrigger as-child>
        <Button variant="outline">side: {{ side }}</Button>
      </PopoverTrigger>
      <PopoverContent :side="side">
        <p class="nx:typography-body-default">Opens on {{ side }}.</p>
      </PopoverContent>
    </Popover>
    <Popover v-for="align in aligns" :key="align">
      <PopoverTrigger as-child>
        <Button variant="outline">align: {{ align }}</Button>
      </PopoverTrigger>
      <PopoverContent :align="align">
        <p class="nx:typography-body-default">Aligned {{ align }}.</p>
      </PopoverContent>
    </Popover>
  </div>
</template>

With anchor

Wrap another element in PopoverAnchor to position the popover against it instead of the trigger.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from '@/components/popover';
</script>

<template>
  <Popover>
    <PopoverAnchor as-child>
      <div
        class="nx:rounded-md nx:border-default nx:border-border-default nx:bg-muted nx:p-container nx:typography-label-default nx:text-muted-foreground"
      >
        The popover positions against this anchor box.
      </div>
    </PopoverAnchor>
    <div class="nx:mt-4">
      <PopoverTrigger as-child>
        <Button variant="outline">Toggle popover</Button>
      </PopoverTrigger>
    </div>
    <PopoverContent>
      <p class="nx:typography-body-default">
        Positioned relative to the anchor box, not the trigger button.
      </p>
    </PopoverContent>
  </Popover>
</template>

Props

PopoverContent

PopoverContent adds no props of its own; it accepts the props of the element or primitive it wraps.