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>
);
}
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.
'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>
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.