Skip to main content
Nexus Design System
GitHub

Tooltip

Tooltip shows a short hint when users hover over or focus a trigger. Use it for brief labels, such as the name of an icon button. Wrap a group of tooltips in TooltipProvider to share one open delay.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

export default function TooltipDefault() {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button variant="outline">Hover me</Button>
      </TooltipTrigger>
      <TooltipContent>
        <p>This is a tooltip</p>
      </TooltipContent>
    </Tooltip>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add tooltip

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

Usage

import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

Composition

Tooltip
├── TooltipTrigger
└── TooltipContent

Examples

Sides

Set side on TooltipContent to open the tooltip on any of four sides.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

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

export default function TooltipSides() {
  return (
    <div className="nx:flex nx:flex-wrap nx:gap-4">
      {SIDES.map(({ side, label }) => (
        <Tooltip key={side}>
          <TooltipTrigger asChild>
            <Button variant="outline">{label}</Button>
          </TooltipTrigger>
          <TooltipContent side={side}>
            <p>Tooltip on the {side}</p>
          </TooltipContent>
        </Tooltip>
      ))}
    </div>
  );
}

With long content

Cap the width with a class so long text wraps onto several lines.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

export default function TooltipWithLongContent() {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button variant="outline">Hover for details</Button>
      </TooltipTrigger>
      <TooltipContent className="nx:max-w-xs">
        <p>
          This tooltip contains longer content that might wrap to multiple lines
          when it exceeds the maximum width.
        </p>
      </TooltipContent>
    </Tooltip>
  );
}

With custom offset

Set sideOffset to change the gap between trigger and tooltip.

'use client';

import { Button } from '@/components/nxds/components/button/button';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

export default function TooltipWithCustomOffset() {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button variant="outline">Custom offset</Button>
      </TooltipTrigger>
      <TooltipContent sideOffset={12}>
        <p>12px offset from trigger</p>
      </TooltipContent>
    </Tooltip>
  );
}

Props

Tooltip

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

TooltipContent

TooltipContent props
PropTypeDefaultDescription
classNamestringNo default documentedNo description

TooltipProvider

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

TooltipTrigger

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

Tooltip shows a short hint when users hover over or focus a trigger. Use it for brief labels, such as the name of an icon button. Wrap a group of tooltips in TooltipProvider to share one open delay.

<script setup lang="ts">
import { Button } from '@/components/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';
</script>

<template>
  <Tooltip>
    <TooltipTrigger as-child>
      <Button variant="outline">Hover me</Button>
    </TooltipTrigger>
    <TooltipContent>
      <p>This is a tooltip</p>
    </TooltipContent>
  </Tooltip>
</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/tooltip/Tooltip.vue
    <script setup lang="ts">
    import {
      TooltipProvider,
      TooltipRoot,
      type TooltipRootEmits,
      type TooltipRootProps,
      useForwardPropsEmits,
    } from 'reka-ui';
    import { inject } from 'vue';
    
    import { TooltipGroupKey } from './tooltip-group';
    
    export type TooltipProps = TooltipRootProps;
    
    const props = defineProps<TooltipProps>();
    const emits = defineEmits<TooltipRootEmits>();
    const forwarded = useForwardPropsEmits(props, emits);
    
    const inGroup = inject(TooltipGroupKey, false);
    </script>
    
    <template>
      <TooltipRoot v-if="inGroup" v-bind="forwarded">
        <slot />
      </TooltipRoot>
      <TooltipProvider v-else>
        <TooltipRoot v-bind="forwarded">
          <slot />
        </TooltipRoot>
      </TooltipProvider>
    </template>
    
    components/tooltip/TooltipContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      TooltipContent,
      type TooltipContentEmits,
      type TooltipContentProps as RekaTooltipContentProps,
      TooltipPortal,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import {
      overlayFloatingTransitionClassName,
      tooltipSurfaceClassName,
    } from '../overlay-layout/overlay-layout';
    
    export interface TooltipContentProps extends RekaTooltipContentProps {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<TooltipContentProps>(), {
      sideOffset: 4,
    });
    const emits = defineEmits<TooltipContentEmits>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <TooltipPortal>
        <TooltipContent
          v-bind="{ ...$attrs, ...forwarded }"
          data-slot="tooltip-content"
          :class="
            cn(
              'nx:z-popover nx:overflow-hidden',
              tooltipSurfaceClassName,
              'nx:px-3 nx:py-1.5',
              'nx:typography-body-small',
              overlayFloatingTransitionClassName,
              props.class
            )
          "
        >
          <slot />
        </TooltipContent>
      </TooltipPortal>
    </template>
    
    components/tooltip/TooltipProvider.vue
    <script setup lang="ts">
    import {
      TooltipProvider,
      type TooltipProviderProps as RekaTooltipProviderProps,
      useForwardProps,
    } from 'reka-ui';
    import { provide } from 'vue';
    
    import { TooltipGroupKey } from './tooltip-group';
    
    export type TooltipProviderProps = RekaTooltipProviderProps;
    
    const props = defineProps<TooltipProviderProps>();
    const forwarded = useForwardProps(props);
    
    provide(TooltipGroupKey, true);
    </script>
    
    <template>
      <TooltipProvider v-bind="forwarded">
        <slot />
      </TooltipProvider>
    </template>
    
    components/tooltip/index.ts
    export { default as Tooltip, type TooltipProps } from './Tooltip.vue';
    export {
      default as TooltipContent,
      type TooltipContentProps,
    } from './TooltipContent.vue';
    export {
      default as TooltipProvider,
      type TooltipProviderProps,
    } from './TooltipProvider.vue';
    export { TooltipTrigger, type TooltipTriggerProps } from 'reka-ui';
    
    components/tooltip/tooltip-group.ts
    import type { InjectionKey } from 'vue';
    
    /** True inside a `TooltipProvider`. */
    export const TooltipGroupKey: InjectionKey<boolean> = Symbol('tooltip-group');
    
  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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';

Examples

Sides

Set side on TooltipContent to open the tooltip on any of four sides.

<script setup lang="ts">
import { Button } from '@/components/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';

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">
    <Tooltip v-for="{ side, label } in sides" :key="side">
      <TooltipTrigger as-child>
        <Button variant="outline">{{ label }}</Button>
      </TooltipTrigger>
      <TooltipContent :side="side">
        <p>Tooltip on the {{ side }}</p>
      </TooltipContent>
    </Tooltip>
  </div>
</template>

With long content

Cap the width with a class so long text wraps onto several lines.

<script setup lang="ts">
import { Button } from '@/components/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';
</script>

<template>
  <Tooltip>
    <TooltipTrigger as-child>
      <Button variant="outline">Hover for details</Button>
    </TooltipTrigger>
    <TooltipContent class="nx:max-w-xs">
      <p>
        This tooltip contains longer content that might wrap to multiple lines
        when it exceeds the maximum width.
      </p>
    </TooltipContent>
  </Tooltip>
</template>

With custom offset

Set sideOffset to change the gap between trigger and tooltip.

<script setup lang="ts">
import { Button } from '@/components/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';
</script>

<template>
  <Tooltip>
    <TooltipTrigger as-child>
      <Button variant="outline">Custom offset</Button>
    </TooltipTrigger>
    <TooltipContent :side-offset="12">
      <p>12px offset from trigger</p>
    </TooltipContent>
  </Tooltip>
</template>

Props

Tooltip

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

TooltipContent

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

TooltipProvider

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