Skip to content
  1. Overlays
  2. Tooltip

Tooltip

A short label that appears when a control is hovered or focused.

import { Button } from '@/components/button'
import { Tooltip } from '@/components/tooltip'

export default function Example() {
  return (
    <Tooltip label="Delete this ticket">
      <Button color="red">Delete</Button>
    </Tooltip>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Tooltip from '@/components/tooltip.vue'
</script>

<template>
  <Tooltip label="Delete this ticket">
    <Button color="red">Delete</Button>
  </Tooltip>
</template>

Dependencies

Positioning comes from Floating UI, which Headless UI already depends on. Install it explicitly so the import does not rely on hoisting:

npm install @floating-ui/react
pnpm add @floating-ui/react
yarn add @floating-ui/react
bun add @floating-ui/react
npm install reka-ui
pnpm add reka-ui
yarn add reka-ui
bun add reka-ui

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import {
  autoUpdate,
  flip,
  FloatingPortal,
  offset,
  safePolygon,
  shift,
  useDismiss,
  useFloating,
  useFocus,
  useHover,
  useInteractions,
  useMergeRefs,
  useRole,
  type Placement,
} from '@floating-ui/react'
import React, { cloneElement, useState } from 'react'

export function Tooltip({
  label,
  content,
  side = 'top',
  delay = 150,
  disabled = false,
  children,
}: {
  label?: string
  content?: React.ReactNode
  side?: Placement
  delay?: number
  disabled?: boolean
  children: React.ReactElement<Record<string, unknown>>
}) {
  let [open, setOpen] = useState(false)

  let { refs, floatingStyles, context } = useFloating({
    open,
    onOpenChange: setOpen,
    placement: side,
    middleware: [offset(6), flip(), shift({ padding: 8 })],
    whileElementsMounted: autoUpdate,
  })

  let { getReferenceProps, getFloatingProps } = useInteractions([
    useHover(context, { delay: { open: delay, close: 0 }, move: false, handleClose: safePolygon() }),
    useFocus(context),
    useDismiss(context),
    useRole(context, { role: 'tooltip' }),
  ])

  let ref = useMergeRefs([refs.setReference, (children.props as { ref?: React.Ref<Element> }).ref])

  if (disabled) return children

  return (
    <>
      {cloneElement(children, getReferenceProps({ ...children.props, ref }))}
      {open && (
        <FloatingPortal>
          <div
            ref={refs.setFloating}
            style={floatingStyles}
            {...getFloatingProps()}
            className="z-50 flex items-center gap-1.5 rounded-md bg-zinc-950 px-2 py-1 text-xs/5 font-medium text-white dark:bg-zinc-700"
          >
            {content ?? label}
          </div>
        </FloatingPortal>
      )}
    </>
  )
}
<script lang="ts">
export type TooltipSide = 'top' | 'right' | 'bottom' | 'left'
export type TooltipPlacement = TooltipSide | `${TooltipSide}-start` | `${TooltipSide}-end`
</script>

<script setup lang="ts">
import {
  injectTooltipProviderContext,
  TooltipContent,
  TooltipPortal,
  TooltipProvider,
  TooltipRoot,
  TooltipTrigger,
} from 'reka-ui'
import { computed, type SetupContext } from 'vue'

const props = withDefaults(
  defineProps<{
    label?: string
    side?: TooltipPlacement
    delay?: number
    disabled?: boolean
  }>(),
  { label: undefined, side: 'top', delay: 150, disabled: false },
)

const Passthrough = (_: unknown, { slots }: SetupContext) => slots.default?.()

const Provider = injectTooltipProviderContext(null) ? Passthrough : TooltipProvider

const placement = computed(() => {
  const [side, align = 'center'] = props.side.split('-') as [TooltipSide, 'start' | 'end' | 'center' | undefined]
  return { side, align }
})
</script>

<template>
  <component :is="Provider">
    <TooltipRoot :disabled="disabled" :delay-duration="delay">
      <TooltipTrigger as-child>
        <slot />
      </TooltipTrigger>
      <TooltipPortal>
        <TooltipContent
          :side="placement.side"
          :align="placement.align"
          :side-offset="6"
          class="z-50 flex items-center gap-1.5 rounded-md bg-zinc-950 px-2 py-1 text-xs/5 font-medium text-white dark:bg-zinc-700"
        >
          <slot name="content">{{ label }}</slot>
        </TooltipContent>
      </TooltipPortal>
    </TooltipRoot>
  </component>
</template>

Component API

PropDefaultDescription
Tooltiprenders a <div> element
label-The text shown in the bubble.
content-Richer than a label: an icon, a badge. Replaces `label`.
disabledfalseLets the control through without ever opening the tooltip: a collapsed sidebar expanded again.
side'top'Preferred placement, as accepted by Floating UI.
delay150Milliseconds to hover before the tooltip appears.
children-The control being described. It has to forward its ref.
PropDefaultDescription
Tooltipbuilt on the Reka UI <TooltipRoot> component
label-The text shown in the bubble.
#content-Richer than a label: an icon, a badge. Replaces `label`.
disabledfalseLets the control through without ever opening the tooltip: a collapsed sidebar expanded again.
side'top'Preferred placement: `top`, `right`, `bottom` or `left`, optionally followed by `-start` or `-end`.
delay150Milliseconds to hover before the tooltip appears.

Examples

Basic example

Wrap the control you want to describe. The trigger keeps its own markup: the tooltip only attaches its handlers and its ref to it:

import { Button } from '@/components/button'
import { Tooltip } from '@/components/tooltip'

export default function Example() {
  return (
    <Tooltip label="Delete this ticket">
      <Button color="red">Delete</Button>
    </Tooltip>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Tooltip from '@/components/tooltip.vue'
</script>

<template>
  <Tooltip label="Delete this ticket">
    <Button color="red">Delete</Button>
  </Tooltip>
</template>

Placement

Use side to pick where the bubble should go. It flips automatically when there is no room:

import { Button } from '@/components/button'
import { Tooltip } from '@/components/tooltip'

export default function Example() {
  return (
    <>
      <Tooltip label="Above" side="top"><Button outline>Top</Button></Tooltip>
      <Tooltip label="To the right" side="right"><Button outline>Right</Button></Tooltip>
      <Tooltip label="Below" side="bottom"><Button outline>Bottom</Button></Tooltip>
    </>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Tooltip from '@/components/tooltip.vue'
</script>

<template>
  <Tooltip label="Above" side="top"><Button outline>Top</Button></Tooltip>
  <Tooltip label="To the right" side="right"><Button outline>Right</Button></Tooltip>
  <Tooltip label="Below" side="bottom"><Button outline>Bottom</Button></Tooltip>
</template>

On an icon button

A tooltip is not a substitute for a name: keep an aria-label on the button, because the bubble never opens for a screen reader:

import { Button } from '@/components/button'
import { Tooltip } from '@/components/tooltip'
import { TrashIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <Tooltip label="Delete">
      <Button plain aria-label="Delete">
        <TrashIcon />
      </Button>
    </Tooltip>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Tooltip from '@/components/tooltip.vue'
import { TrashIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <Tooltip label="Delete">
    <Button plain aria-label="Delete">
      <TrashIcon />
    </Button>
  </Tooltip>
</template>