Skip to main content
Nexus Design System
GitHub

HoverCard

HoverCard shows a preview card when the user hovers or focuses a trigger. Use it for profile and link previews. For content opened by a click, use Popover.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/nxds/components/hover-card/hover-card';

export default function HoverCardDefault() {
  return (
    <HoverCard>
      <HoverCardTrigger asChild>
        <Button variant="link">@nxds</Button>
      </HoverCardTrigger>
      <HoverCardContent>
        <div className="nx:flex nx:flex-col nx:gap-1">
          <p className="nx:typography-label-default nx:font-semibold nx:text-foreground">
            @nxds
          </p>
          <p className="nx:typography-body-default nx:text-muted-foreground">
            The AI-native design system. Joined March 2026.
          </p>
        </div>
      </HoverCardContent>
    </HoverCard>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add hover-card

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

Usage

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/nxds/components/hover-card/hover-card';

Composition

HoverCard
├── HoverCardTrigger
└── HoverCardContent

Examples

Profile card

Show an avatar, bio and join date in a wider preview card.

'use client';

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from '@/components/nxds/components/avatar/avatar';
import { Button } from '@/components/nxds/components/button/button';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/nxds/components/hover-card/hover-card';

export default function HoverCardProfileCard() {
  return (
    <HoverCard>
      <HoverCardTrigger asChild>
        <Button variant="link">@ada</Button>
      </HoverCardTrigger>
      <HoverCardContent className="nx:w-80">
        <div className="nx:flex nx:gap-4">
          <Avatar>
            <AvatarImage src="/avatars/ada.svg" alt="Ada Lovelace" />
            <AvatarFallback>AL</AvatarFallback>
          </Avatar>
          <div className="nx:flex nx:flex-col nx:gap-1">
            <p className="nx:typography-label-default nx:font-semibold nx:text-foreground">
              Ada Lovelace
            </p>
            <p className="nx:typography-body-default nx:text-muted-foreground">
              Wrote the first program for the Analytical Engine.
            </p>
            <p className="nx:typography-label-small nx:text-muted-foreground">
              Joined December 1843
            </p>
          </div>
        </div>
      </HoverCardContent>
    </HoverCard>
  );
}

This example also needs: Avatar.

Placement

Set side on HoverCardContent to open the card on any edge of the trigger.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/nxds/components/hover-card/hover-card';

const SIDES = [
  { side: 'top', label: 'Top' },
  { side: 'right', label: 'Right' },
  { side: 'bottom', label: 'Bottom' },
  { side: 'left', label: 'Left' },
] as const;

export default function HoverCardPlacement() {
  return (
    <div className="nx:flex nx:flex-wrap nx:gap-4">
      {SIDES.map(({ side, label }) => (
        <HoverCard key={side}>
          <HoverCardTrigger asChild>
            <Button variant="outline">{label}</Button>
          </HoverCardTrigger>
          <HoverCardContent side={side}>
            Opens on the {side} of its trigger.
          </HoverCardContent>
        </HoverCard>
      ))}
    </div>
  );
}

Props

HoverCard

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

HoverCardContent

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

HoverCardTrigger

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

HoverCard shows a preview card when the user hovers or focuses a trigger. Use it for profile and link previews. For content opened by a click, use Popover.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/hover-card';
</script>

<template>
  <HoverCard>
    <HoverCardTrigger as-child>
      <Button variant="link">@nxds</Button>
    </HoverCardTrigger>
    <HoverCardContent>
      <div class="nx:flex nx:flex-col nx:gap-1">
        <p
          class="nx:typography-label-default nx:font-semibold nx:text-foreground"
        >
          @nxds
        </p>
        <p class="nx:typography-body-default nx:text-muted-foreground">
          The AI-native design system. Joined March 2026.
        </p>
      </div>
    </HoverCardContent>
  </HoverCard>
</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/hover-card/HoverCardContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      type FocusOutsideEvent,
      HoverCardContent,
      type HoverCardContentProps as RekaHoverCardContentProps,
      HoverCardPortal,
      type PointerDownOutsideEvent,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import {
      overlayFloatingTransitionClassName,
      popoverSurfaceClassName,
    } from '../overlay-layout/overlay-layout';
    
    export interface HoverCardContentProps extends RekaHoverCardContentProps {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<HoverCardContentProps>(), {
      align: 'center',
      sideOffset: 4,
    });
    
    // Reka emits these from HoverCardContent but exports no type for them.
    const emits = defineEmits<{
      escapeKeyDown: [event: KeyboardEvent];
      pointerDownOutside: [event: PointerDownOutsideEvent];
      focusOutside: [event: FocusOutsideEvent];
      interactOutside: [event: PointerDownOutsideEvent | FocusOutsideEvent];
    }>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <HoverCardPortal>
        <HoverCardContent
          v-bind="{ ...$attrs, ...forwarded }"
          data-slot="hover-card-content"
          :class="
            cn(
              'nx:z-popover nx:w-64 nx:p-container nx:outline-none',
              popoverSurfaceClassName,
              overlayFloatingTransitionClassName,
              props.class
            )
          "
        >
          <slot />
        </HoverCardContent>
      </HoverCardPortal>
    </template>
    
    components/hover-card/HoverCardTrigger.vue
    <script setup lang="ts">
    import {
      HoverCardTrigger,
      type HoverCardTriggerProps as RekaHoverCardTriggerProps,
    } from 'reka-ui';
    
    export type HoverCardTriggerProps = RekaHoverCardTriggerProps;
    
    const props = defineProps<HoverCardTriggerProps>();
    </script>
    
    <template>
      <HoverCardTrigger v-bind="props" data-slot="hover-card-trigger">
        <slot />
      </HoverCardTrigger>
    </template>
    
    components/hover-card/index.ts
    export {
      default as HoverCardContent,
      type HoverCardContentProps,
    } from './HoverCardContent.vue';
    export {
      default as HoverCardTrigger,
      type HoverCardTriggerProps,
    } from './HoverCardTrigger.vue';
    export {
      HoverCardRoot as HoverCard,
      type HoverCardRootProps as HoverCardProps,
    } from 'reka-ui';
    
    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';
    }
    
  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 { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/hover-card';

Examples

Profile card

Show an avatar, bio and join date in a wider preview card.

<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/avatar';
import { Button } from '@/components/button';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/hover-card';
</script>

<template>
  <HoverCard>
    <HoverCardTrigger as-child>
      <Button variant="link">@ada</Button>
    </HoverCardTrigger>
    <HoverCardContent class="nx:w-80">
      <div class="nx:flex nx:gap-4">
        <Avatar>
          <AvatarImage src="/avatars/ada.svg" alt="Ada Lovelace" />
          <AvatarFallback>AL</AvatarFallback>
        </Avatar>
        <div class="nx:flex nx:flex-col nx:gap-1">
          <p
            class="nx:typography-label-default nx:font-semibold nx:text-foreground"
          >
            Ada Lovelace
          </p>
          <p class="nx:typography-body-default nx:text-muted-foreground">
            Wrote the first program for the Analytical Engine.
          </p>
          <p class="nx:typography-label-small nx:text-muted-foreground">
            Joined December 1843
          </p>
        </div>
      </div>
    </HoverCardContent>
  </HoverCard>
</template>

This example also needs: Avatar.

Placement

Set side on HoverCardContent to open the card on any edge of the trigger.

<script setup lang="ts">
import { Button } from '@/components/button';
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from '@/components/hover-card';

const sides = [
  { side: 'top', label: 'Top' },
  { side: 'right', label: 'Right' },
  { side: 'bottom', label: 'Bottom' },
  { side: 'left', label: 'Left' },
] as const;
</script>

<template>
  <div class="nx:flex nx:flex-wrap nx:gap-4">
    <HoverCard v-for="{ side, label } in sides" :key="side">
      <HoverCardTrigger as-child>
        <Button variant="outline">{{ label }}</Button>
      </HoverCardTrigger>
      <HoverCardContent :side="side"
        >Opens on the {{ side }} of its trigger.</HoverCardContent
      >
    </HoverCard>
  </div>
</template>

Props

HoverCardContent

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

HoverCardTrigger

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