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/reactpnpm add @floating-ui/reactyarn add @floating-ui/reactbun add @floating-ui/reactnpm install reka-uipnpm add reka-uiyarn add reka-uibun add reka-uiInstallation
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
| Prop | Default | Description |
|---|---|---|
Tooltiprenders a <div> element | ||
label | - | The text shown in the bubble. |
content | - | Richer than a label: an icon, a badge. Replaces `label`. |
disabled | false | Lets the control through without ever opening the tooltip: a collapsed sidebar expanded again. |
side | 'top' | Preferred placement, as accepted by Floating UI. |
delay | 150 | Milliseconds to hover before the tooltip appears. |
children | - | The control being described. It has to forward its ref. |
| Prop | Default | Description |
|---|---|---|
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`. |
disabled | false | Lets 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`. |
delay | 150 | Milliseconds 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>