Skip to content

Tooltip

Tooltip adds short supplementary text to one real button or link. Wrap the trigger, provide the text, and keep the trigger's semantics and Tailwind classes where they are visible.

Klean supplies the accessible description, hover and keyboard behavior, collision-safe placement, Escape dismissal, touch behavior, and cleanup. There are no trigger IDs, compound components, providers, visual variants, or configuration ceremony.

Tooltip.vue

Installation

One command detects Vue, React, or Svelte, writes the matching source, and installs its direct dependencies:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add tooltip

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

The installed source belongs to the application. There is no initializer, provider, generated ID to coordinate, configuration file, or shared class helper.

Usage

The child is the trigger. Use a real button for an action and a real anchor or Boring Stack Link for navigation.

Vue

QueryToolbar.vue
<script setup>
import Button from '@/components/ui/button/Button.vue'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
</script>

<template>
  <Tooltip text="Re-run query">
    <Button type="button" aria-label="Re-run query" class="size-10 min-h-0 p-0">
      <RefreshIcon aria-hidden="true" />
    </Button>
  </Tooltip>
</template>

React

QueryToolbar.jsx
import Button from '@/components/ui/button/Button.jsx'
import Tooltip from '@/components/ui/tooltip/Tooltip.jsx'

export default function QueryToolbar() {
  return (
    <Tooltip text="Re-run query">
      <Button
        type="button"
        aria-label="Re-run query"
        className="size-10 min-h-0 p-0"
      >
        <RefreshIcon aria-hidden="true" />
      </Button>
    </Tooltip>
  )
}

Svelte

QueryToolbar.svelte
<script>
  import Button from '$lib/components/ui/button/Button.svelte'
  import Tooltip from '$lib/components/ui/tooltip/Tooltip.svelte'
</script>

<Tooltip text="Re-run query">
  <Button type="button" aria-label="Re-run query" class="size-10 min-h-0 p-0">
    <RefreshIcon aria-hidden="true" />
  </Button>
</Tooltip>

The icon is decorative because the button already has the accessible name “Re-run query.” Tooltip text supplements the control; it does not replace the button's name.

API

InputDefaultPurpose
textrequiredShort, non-interactive supplementary text.
placementtopPreferred top, right, bottom, or left side; may flip to fit.
offset8Pixel distance between the trigger and tooltip.
class / classNameOrdinary Tailwind classes merged last on the tooltip surface.
default childrequiredOne semantic button, anchor, or framework Link that renders an anchor.

Other non-conflicting attributes are forwarded to the tooltip surface. Trigger attributes and styling stay on the trigger itself.

Semantics before appearance

Tooltip never decides what the trigger means:

  • use <button type="button"> for an action;
  • use <a href="…"> for navigation;
  • use the Boring Stack Link component when navigation should retain client-side routing semantics;
  • give an icon-only trigger its own accessible name with aria-label or visible text;
  • keep the icon itself decorative with aria-hidden="true".

Klean adds its generated description to the trigger without discarding an existing aria-describedby relationship. It removes only its own relationship when the component unmounts.

Do not put links, buttons, fields, headings, or long instructions inside a Tooltip. Use Popover for interactive or structured content.

Theme without configuration

The neutral default stays visually distinct from the application surface: it is dark in a light colour scheme and light in a dark colour scheme. Tooltip follows the application's ordinary Tailwind dark: state, so normal usage needs no theme prop, provider, colour calculation, or configuration.

TooltipTheme.vue

The theme signal handles the ordinary case. If a branded or isolated local surface deliberately differs from the application scheme, pass the complete light and dark treatment through class or className; caller classes merge last.

Styling with Tailwind

Style the trigger on the trigger. Style the floating surface through Tooltip's ordinary class input:

Vue

invoice-tooltip.vue
<Tooltip
  text="Copy public invoice link"
  placement="bottom"
  class="rounded-none border-2 border-black bg-amber-50 px-3 py-2 font-mono text-[11px] uppercase tracking-wider text-black shadow-[3px_3px_0_0_#000] dark:border-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:shadow-[3px_3px_0_0_#fde68a]"
>
  <button
    type="button"
    aria-label="Copy public invoice link"
    class="grid size-12 place-items-center border-2 border-black bg-black text-white"
  >
    <CopyIcon aria-hidden="true" />
  </button>
</Tooltip>

React

invoice-tooltip.jsx
export default function InvoiceTooltip() {
  return (
    <Tooltip
      text="Copy public invoice link"
      placement="bottom"
      className="rounded-none border-2 border-black bg-amber-50 px-3 py-2 font-mono text-[11px] uppercase tracking-wider text-black shadow-[3px_3px_0_0_#000] dark:border-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:shadow-[3px_3px_0_0_#fde68a]"
    >
      <button
        type="button"
        aria-label="Copy public invoice link"
        className="grid size-12 place-items-center border-2 border-black bg-black text-white"
      >
        <CopyIcon aria-hidden="true" />
      </button>
    </Tooltip>
  )
}

Svelte

invoice-tooltip.svelte
<Tooltip
  text="Copy public invoice link"
  placement="bottom"
  class="rounded-none border-2 border-black bg-amber-50 px-3 py-2 font-mono text-[11px] uppercase tracking-wider text-black shadow-[3px_3px_0_0_#000] dark:border-amber-200 dark:bg-amber-950 dark:text-amber-50 dark:shadow-[3px_3px_0_0_#fde68a]"
>
  <button
    type="button"
    aria-label="Copy public invoice link"
    class="grid size-12 place-items-center border-2 border-black bg-black text-white"
  >
    <CopyIcon aria-hidden="true" />
  </button>
</Tooltip>

The arrow belongs to Tooltip. It inherits the surface colour and follows the collision-resolved side, so it still points to the trigger when the preferred placement flips or shifts near a viewport edge. data-slot="tooltip" and data-slot="tooltip-arrow" remain stable nearby styling hooks; there is no arrow, tone, size, radius, elevation, or variant prop.

Keep the arrow visible. It communicates which control the supplementary text describes while ordinary Tailwind classes remain free to restyle the surface.

Repeated product treatment can become a small application-owned wrapper. Hagfish and Slipway can therefore share the accessible behavior and inverse neutral default without sharing a visual identity.

Accessible behavior

  • Pointer hover and keyboard focus reveal the same supplementary text.
  • Escape dismisses an open Tooltip without moving focus away from its trigger.
  • Moving between the trigger and Tooltip does not dismiss it prematurely.
  • Only one Tooltip is open at a time.
  • Touch activation does not create a sticky synthetic hover surface.
  • Placement flips or shifts when the preferred side would leave the viewport, and the arrow follows the resolved side and clamped cross-axis position.
  • Reduced-motion and forced-colors preferences retain a useful, readable result.
  • The trigger remains the actual focusable element; Tooltip does not add a styled trigger wrapper.

The text remains supplementary. A critical label, validation error, destructive warning, or operation result must stay visible in the interface.

Durable behavior

Tooltip visibility is ephemeral. It is derived from current hover or focus and is never written to local storage, the URL, cookies, or server state. Timers, position observers, generated accessibility relationships, and global listeners are cleaned up when no longer needed.

The durable state belongs to the application action or destination—not to whether its explanation happened to be visible.

Complete framework source

Copy, inspect, and change the complete source for your framework.

Vue source

Tooltip.vue
<script>
let closeActiveTooltip
</script>

<script setup>
import {
  arrow as floatingArrow,
  autoUpdate,
  computePosition,
  flip,
  offset as floatingOffset,
  shift
} from '@floating-ui/dom'
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  useId
} from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** Short supplementary text. Interactive content belongs in Popover. */
  text: { type: String, required: true },
  /** Preferred side. Collision handling may flip it. */
  placement: {
    type: String,
    default: 'top',
    validator: (value) => ['top', 'right', 'bottom', 'left'].includes(value)
  },
  /** Space in pixels between the trigger and floating surface. */
  offset: { type: Number, default: 8 }
})

const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const tooltipId = `klean-tooltip-${generatedId}`
const root = ref()
const trigger = ref()
const content = ref()
const arrow = ref()
const isOpen = ref(false)
const supportsNative = ref(false)
const resolvedPlacement = ref(props.placement)
const positionStyle = ref({ position: 'fixed', left: '0px', top: '0px' })
const arrowStyle = ref({})
let openTimer
let closeTimer
let cleanupPosition = () => {}
let observer
let lastTouchAt = 0

const OPEN_DELAY = 400
const CLOSE_DELAY = 80
const ARROW_OVERHANG = 8
const ARROW_CLIP_PATHS = {
  top: 'polygon(0 0, 100% 0, 50% 100%)',
  right: 'polygon(100% 0, 0 50%, 100% 100%)',
  bottom: 'polygon(50% 0, 100% 100%, 0 100%)',
  left: 'polygon(0 0, 100% 50%, 0 100%)'
}

const contentAttrs = computed(() => {
  const {
    class: _class,
    style: _style,
    id: _id,
    role: _role,
    popover: _popover,
    hidden: _hidden,
    'data-slot': _dataSlot,
    ...rest
  } = attrs
  return rest
})

const contentClasses = computed(() =>
  twMerge(
    [
      'z-50 m-0 w-max max-w-[calc(100vw-1rem)] overflow-visible rounded-md border border-gray-950 bg-gray-950 px-2.5 py-1.5 text-xs font-medium leading-none text-white shadow-md outline-none',
      'transition-opacity duration-100 starting:opacity-0 motion-reduce:transition-none',
      'dark:border-white dark:bg-white dark:text-gray-950',
      'forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]'
    ],
    attrs.class
  )
)

function descriptionTokens(element) {
  return (element?.getAttribute('aria-describedby') ?? '')
    .split(/\s+/)
    .filter(Boolean)
}

function addDescription(element) {
  if (!element) return
  const tokens = new Set(descriptionTokens(element))
  tokens.add(tooltipId)
  element.setAttribute('aria-describedby', [...tokens].join(' '))
}

function removeDescription(element) {
  if (!element) return
  const tokens = descriptionTokens(element).filter(
    (token) => token !== tooltipId
  )
  if (tokens.length) element.setAttribute('aria-describedby', tokens.join(' '))
  else element.removeAttribute('aria-describedby')
}

function syncTrigger() {
  const nextTrigger = root.value?.firstElementChild
  if (nextTrigger === trigger.value) return

  removeDescription(trigger.value)
  trigger.value = nextTrigger
  addDescription(trigger.value)

  if (!trigger.value) closeNow()
}

function popoverIsShowing() {
  if (!supportsNative.value || !content.value) return false
  try {
    return content.value.matches(':popover-open')
  } catch {
    return false
  }
}

function syncNativePopover() {
  if (!supportsNative.value || !content.value) return
  try {
    if (isOpen.value && !popoverIsShowing()) {
      content.value.showPopover({ source: trigger.value })
    } else if (!isOpen.value && popoverIsShowing()) {
      content.value.hidePopover()
    }
  } catch {
    // Rapid pointer and focus changes can make show/hide requests redundant.
  }
}

async function updatePosition() {
  if (!trigger.value?.isConnected || !content.value?.isConnected) {
    closeNow()
    return
  }

  const result = await computePosition(trigger.value, content.value, {
    placement: props.placement,
    strategy: 'fixed',
    middleware: [
      floatingOffset(props.offset),
      flip(),
      shift({ padding: 8 }),
      floatingArrow({ element: arrow.value, padding: 6 })
    ]
  })

  const side = result.placement.split('-')[0]
  const staticSide = {
    top: 'bottom',
    right: 'left',
    bottom: 'top',
    left: 'right'
  }[side]
  const arrowData = result.middlewareData.arrow ?? {}

  resolvedPlacement.value = result.placement
  positionStyle.value = {
    position: 'fixed',
    left: `${result.x}px`,
    top: `${result.y}px`
  }
  arrowStyle.value = {
    left: arrowData.x == null ? '' : `${arrowData.x}px`,
    top: arrowData.y == null ? '' : `${arrowData.y}px`,
    right: '',
    bottom: '',
    clipPath: ARROW_CLIP_PATHS[side],
    [staticSide]: `-${ARROW_OVERHANG}px`
  }
}

function startPositioning() {
  cleanupPosition()
  if (!trigger.value || !content.value) return
  cleanupPosition = autoUpdate(trigger.value, content.value, updatePosition)
}

async function openNow() {
  clearTimeout(openTimer)
  clearTimeout(closeTimer)
  syncTrigger()
  if (!trigger.value || !props.text) return

  if (closeActiveTooltip && closeActiveTooltip !== closeNow) {
    closeActiveTooltip()
  }
  closeActiveTooltip = closeNow
  isOpen.value = true
  syncNativePopover()
  await nextTick()
  startPositioning()
}

function closeNow() {
  clearTimeout(openTimer)
  clearTimeout(closeTimer)
  cleanupPosition()
  cleanupPosition = () => {}
  isOpen.value = false
  syncNativePopover()
  if (closeActiveTooltip === closeNow) closeActiveTooltip = undefined
}

function scheduleOpen() {
  clearTimeout(closeTimer)
  if (isOpen.value) return
  clearTimeout(openTimer)
  openTimer = setTimeout(openNow, OPEN_DELAY)
}

function scheduleClose() {
  clearTimeout(openTimer)
  clearTimeout(closeTimer)
  closeTimer = setTimeout(closeNow, CLOSE_DELAY)
}

function handlePointerOver(event) {
  if (event.pointerType === 'touch') return
  if (content.value?.contains(event.target)) {
    clearTimeout(closeTimer)
    return
  }
  scheduleOpen()
}

function handlePointerOut(event) {
  if (
    root.value?.contains(event.relatedTarget) ||
    content.value?.contains(event.relatedTarget)
  ) {
    return
  }
  scheduleClose()
}

function handlePointerDown(event) {
  if (event.pointerType !== 'touch') return
  lastTouchAt = Date.now()
  closeNow()
}

function handleFocusIn() {
  if (Date.now() - lastTouchAt < 1000) return
  scheduleOpen()
}

function handleFocusOut(event) {
  if (root.value?.contains(event.relatedTarget)) return
  scheduleClose()
}

function handleEscape(event) {
  if (event.key !== 'Escape' || !isOpen.value) return
  event.preventDefault()
  closeNow()
}

function handleContextChange(event) {
  if (!isOpen.value) return
  const path = event.composedPath?.() ?? [event.target]
  if (path.includes(trigger.value) || path.includes(content.value)) return
  closeNow()
}

function handleNativeToggle(event) {
  if (event.newState === 'closed' && isOpen.value) {
    isOpen.value = false
    cleanupPosition()
    cleanupPosition = () => {}
  }
}

onMounted(async () => {
  await nextTick()
  supportsNative.value =
    typeof content.value?.showPopover === 'function' &&
    typeof content.value?.hidePopover === 'function'
  syncTrigger()

  observer = new MutationObserver(syncTrigger)
  observer.observe(root.value, { childList: true })
  document.addEventListener('keydown', handleEscape)
  document.addEventListener('pointerdown', handleContextChange, true)
  window.addEventListener('blur', closeNow)
})

onBeforeUnmount(() => {
  closeNow()
  removeDescription(trigger.value)
  observer?.disconnect()
  document.removeEventListener('keydown', handleEscape)
  document.removeEventListener('pointerdown', handleContextChange, true)
  window.removeEventListener('blur', closeNow)
})
</script>

<template>
  <span
    ref="root"
    role="presentation"
    class="contents"
    @pointerover="handlePointerOver"
    @pointerout="handlePointerOut"
    @pointerdown="handlePointerDown"
    @focusin="handleFocusIn"
    @focusout="handleFocusOut"
  >
    <slot />
    <div
      v-bind="contentAttrs"
      :id="tooltipId"
      ref="content"
      popover="hint"
      role="tooltip"
      data-slot="tooltip"
      :data-state="isOpen ? 'open' : 'closed'"
      :data-placement="resolvedPlacement"
      :hidden="!supportsNative && !isOpen"
      :class="contentClasses"
      :style="[positionStyle, attrs.style]"
      @pointerenter="clearTimeout(closeTimer)"
      @pointerleave="scheduleClose"
      @toggle="handleNativeToggle"
    >
      {{ text }}
      <span
        ref="arrow"
        aria-hidden="true"
        data-slot="tooltip-arrow"
        class="pointer-events-none absolute size-3 bg-inherit forced-colors:hidden"
        :style="arrowStyle"
      />
    </div>
  </span>
</template>

React source

Tooltip.jsx
import {
  arrow as floatingArrow,
  autoUpdate,
  computePosition,
  flip,
  offset as floatingOffset,
  shift
} from '@floating-ui/dom'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES = [
  'z-50 m-0 w-max max-w-[calc(100vw-1rem)] overflow-visible rounded-md border border-gray-950 bg-gray-950 px-2.5 py-1.5 text-xs font-medium leading-none text-white shadow-md outline-none',
  'transition-opacity duration-100 starting:opacity-0 motion-reduce:transition-none',
  'dark:border-white dark:bg-white dark:text-gray-950',
  'forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]'
]
const OPEN_DELAY = 400
const CLOSE_DELAY = 80
const ARROW_OVERHANG = 8
const ARROW_CLIP_PATHS = {
  top: 'polygon(0 0, 100% 0, 50% 100%)',
  right: 'polygon(100% 0, 0 50%, 100% 100%)',
  bottom: 'polygon(50% 0, 100% 100%, 0 100%)',
  left: 'polygon(0 0, 100% 50%, 0 100%)'
}
let closeActiveTooltip

function descriptionTokens(element) {
  return (element?.getAttribute('aria-describedby') ?? '')
    .split(/\s+/)
    .filter(Boolean)
}

export default function Tooltip({
  text,
  placement = 'top',
  offset = 8,
  className,
  style,
  children,
  ...contentProps
}) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const tooltipId = `klean-tooltip-${generatedId}`
  const rootRef = useRef(null)
  const triggerRef = useRef(null)
  const contentRef = useRef(null)
  const arrowRef = useRef(null)
  const openTimer = useRef()
  const closeTimer = useRef()
  const cleanupPosition = useRef(() => {})
  const lastTouchAt = useRef(0)
  const [isOpen, setIsOpen] = useState(false)
  const [supportsNative, setSupportsNative] = useState(false)
  const [resolvedPlacement, setResolvedPlacement] = useState(placement)
  const [positionStyle, setPositionStyle] = useState({
    position: 'fixed',
    left: 0,
    top: 0
  })
  const [arrowStyle, setArrowStyle] = useState({})

  const removeDescription = useCallback(
    (element) => {
      if (!element) return
      const tokens = descriptionTokens(element).filter(
        (token) => token !== tooltipId
      )
      if (tokens.length) {
        element.setAttribute('aria-describedby', tokens.join(' '))
      } else {
        element.removeAttribute('aria-describedby')
      }
    },
    [tooltipId]
  )

  const syncTrigger = useCallback(() => {
    const nextTrigger = rootRef.current?.firstElementChild
    if (nextTrigger === triggerRef.current) return triggerRef.current

    removeDescription(triggerRef.current)
    triggerRef.current = nextTrigger
    if (nextTrigger) {
      const tokens = new Set(descriptionTokens(nextTrigger))
      tokens.add(tooltipId)
      nextTrigger.setAttribute('aria-describedby', [...tokens].join(' '))
    }
    return nextTrigger
  }, [removeDescription, tooltipId])

  const closeNow = useCallback(() => {
    clearTimeout(openTimer.current)
    clearTimeout(closeTimer.current)
    cleanupPosition.current()
    cleanupPosition.current = () => {}
    setIsOpen(false)
    if (closeActiveTooltip === closeNow) closeActiveTooltip = undefined
  }, [])

  const openNow = useCallback(() => {
    clearTimeout(openTimer.current)
    clearTimeout(closeTimer.current)
    const trigger = syncTrigger()
    if (!trigger || !text) return

    if (closeActiveTooltip && closeActiveTooltip !== closeNow) {
      closeActiveTooltip()
    }
    closeActiveTooltip = closeNow
    setIsOpen(true)
  }, [closeNow, syncTrigger, text])

  const scheduleOpen = useCallback(() => {
    clearTimeout(closeTimer.current)
    if (isOpen) return
    clearTimeout(openTimer.current)
    openTimer.current = setTimeout(openNow, OPEN_DELAY)
  }, [isOpen, openNow])

  const scheduleClose = useCallback(() => {
    clearTimeout(openTimer.current)
    clearTimeout(closeTimer.current)
    closeTimer.current = setTimeout(closeNow, CLOSE_DELAY)
  }, [closeNow])

  useEffect(() => {
    setSupportsNative(
      typeof contentRef.current?.showPopover === 'function' &&
        typeof contentRef.current?.hidePopover === 'function'
    )
    syncTrigger()

    const observer = new MutationObserver(syncTrigger)
    observer.observe(rootRef.current, { childList: true })
    return () => {
      observer.disconnect()
      removeDescription(triggerRef.current)
      closeNow()
    }
  }, [closeNow, removeDescription, syncTrigger])

  useEffect(() => {
    const content = contentRef.current
    const trigger = triggerRef.current
    if (!content) return

    if (supportsNative) {
      let showing = false
      try {
        showing = content.matches(':popover-open')
        if (isOpen && !showing) content.showPopover({ source: trigger })
        else if (!isOpen && showing) content.hidePopover()
      } catch {
        // Rapid pointer and focus changes can make requests redundant.
      }
    }

    cleanupPosition.current()
    cleanupPosition.current = () => {}
    if (!isOpen || !trigger?.isConnected || !content.isConnected) return

    const updatePosition = async () => {
      if (!trigger.isConnected || !content.isConnected) {
        closeNow()
        return
      }

      const result = await computePosition(trigger, content, {
        placement,
        strategy: 'fixed',
        middleware: [
          floatingOffset(offset),
          flip(),
          shift({ padding: 8 }),
          floatingArrow({ element: arrowRef.current, padding: 6 })
        ]
      })
      const side = result.placement.split('-')[0]
      const staticSide = {
        top: 'bottom',
        right: 'left',
        bottom: 'top',
        left: 'right'
      }[side]
      const arrowData = result.middlewareData.arrow ?? {}

      setResolvedPlacement(result.placement)
      setPositionStyle({ position: 'fixed', left: result.x, top: result.y })
      setArrowStyle({
        left: arrowData.x == null ? '' : arrowData.x,
        top: arrowData.y == null ? '' : arrowData.y,
        right: '',
        bottom: '',
        clipPath: ARROW_CLIP_PATHS[side],
        [staticSide]: -ARROW_OVERHANG
      })
    }

    cleanupPosition.current = autoUpdate(trigger, content, updatePosition)
    return () => {
      cleanupPosition.current()
      cleanupPosition.current = () => {}
    }
  }, [closeNow, isOpen, offset, placement, supportsNative])

  useEffect(() => {
    if (!isOpen) return

    function handleEscape(event) {
      if (event.key !== 'Escape') return
      event.preventDefault()
      closeNow()
    }

    function handleContextChange(event) {
      const path = event.composedPath?.() ?? [event.target]
      if (
        path.includes(triggerRef.current) ||
        path.includes(contentRef.current)
      ) {
        return
      }
      closeNow()
    }

    document.addEventListener('keydown', handleEscape)
    document.addEventListener('pointerdown', handleContextChange, true)
    window.addEventListener('blur', closeNow)
    return () => {
      document.removeEventListener('keydown', handleEscape)
      document.removeEventListener('pointerdown', handleContextChange, true)
      window.removeEventListener('blur', closeNow)
    }
  }, [closeNow, isOpen])

  function handlePointerOver(event) {
    if (event.pointerType === 'touch') return
    if (contentRef.current?.contains(event.target)) {
      clearTimeout(closeTimer.current)
      return
    }
    scheduleOpen()
  }

  function handlePointerOut(event) {
    if (
      rootRef.current?.contains(event.relatedTarget) ||
      contentRef.current?.contains(event.relatedTarget)
    ) {
      return
    }
    scheduleClose()
  }

  function handlePointerDown(event) {
    if (event.pointerType !== 'touch') return
    lastTouchAt.current = Date.now()
    closeNow()
  }

  function handleFocusIn() {
    if (Date.now() - lastTouchAt.current < 1000) return
    scheduleOpen()
  }

  function handleFocusOut(event) {
    if (rootRef.current?.contains(event.relatedTarget)) return
    scheduleClose()
  }

  return (
    <span
      ref={rootRef}
      role="presentation"
      className="contents"
      onPointerOver={handlePointerOver}
      onPointerOut={handlePointerOut}
      onPointerDown={handlePointerDown}
      onFocus={handleFocusIn}
      onBlur={handleFocusOut}
    >
      {children}
      <div
        {...contentProps}
        id={tooltipId}
        ref={contentRef}
        popover="hint"
        role="tooltip"
        data-slot="tooltip"
        data-state={isOpen ? 'open' : 'closed'}
        data-placement={resolvedPlacement}
        hidden={!supportsNative && !isOpen}
        className={twMerge(BASE_CLASSES, className)}
        style={{ ...positionStyle, ...style }}
        onPointerEnter={() => clearTimeout(closeTimer.current)}
        onPointerLeave={scheduleClose}
        onToggle={(event) => {
          if (event.newState === 'closed' && isOpen) setIsOpen(false)
          contentProps.onToggle?.(event)
        }}
      >
        {text}
        <span
          ref={arrowRef}
          aria-hidden="true"
          data-slot="tooltip-arrow"
          className="pointer-events-none absolute size-3 bg-inherit forced-colors:hidden"
          style={arrowStyle}
        />
      </div>
    </span>
  )
}

Svelte source

Tooltip.svelte
<script module>
  let closeActiveTooltip;
</script>

<script>
  import {
    arrow as floatingArrow,
    autoUpdate,
    computePosition,
    flip,
    offset as floatingOffset,
    shift,
  } from "@floating-ui/dom";
  import { onMount, untrack } from "svelte";
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES = [
    "z-50 m-0 w-max max-w-[calc(100vw-1rem)] overflow-visible rounded-md border border-gray-950 bg-gray-950 px-2.5 py-1.5 text-xs font-medium leading-none text-white shadow-md outline-none",
    "transition-opacity duration-100 starting:opacity-0 motion-reduce:transition-none",
    "dark:border-white dark:bg-white dark:text-gray-950",
    "forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]",
  ];
  const OPEN_DELAY = 400;
  const CLOSE_DELAY = 80;
  const ARROW_OVERHANG = 8;
  const ARROW_CLIP_PATHS = {
    top: "polygon(0 0, 100% 0, 50% 100%)",
    right: "polygon(100% 0, 0 50%, 100% 100%)",
    bottom: "polygon(50% 0, 100% 100%, 0 100%)",
    left: "polygon(0 0, 100% 50%, 0 100%)",
  };

  let {
    text,
    placement = "top",
    offset = 8,
    class: className = "",
    style,
    children,
    ...contentProps
  } = $props();

  const rawComponentId = $props.id();
  const componentId = rawComponentId.replace(/[^a-zA-Z0-9_-]/g, "");
  const tooltipId = `klean-tooltip-${componentId}`;
  let rootElement;
  let triggerElement = $state();
  let contentElement;
  let arrowElement;
  let isOpen = $state(false);
  let supportsNative = $state(false);
  let resolvedPlacement = $state(untrack(() => placement));
  let positionStyle = $state({ position: "fixed", left: "0px", top: "0px" });
  let arrowStyle = $state({});
  let openTimer;
  let closeTimer;
  let cleanupPosition = () => {};
  let observer;
  let lastTouchAt = 0;

  function descriptionTokens(element) {
    return (element?.getAttribute("aria-describedby") ?? "")
      .split(/\s+/)
      .filter(Boolean);
  }

  function removeDescription(element) {
    if (!element) return;
    const tokens = descriptionTokens(element).filter(
      (token) => token !== tooltipId,
    );
    if (tokens.length)
      element.setAttribute("aria-describedby", tokens.join(" "));
    else element.removeAttribute("aria-describedby");
  }

  function syncTrigger() {
    const nextTrigger = rootElement?.firstElementChild;
    if (nextTrigger === triggerElement) return triggerElement;

    removeDescription(triggerElement);
    triggerElement = nextTrigger;
    if (nextTrigger) {
      const tokens = new Set(descriptionTokens(nextTrigger));
      tokens.add(tooltipId);
      nextTrigger.setAttribute("aria-describedby", [...tokens].join(" "));
    }
    return nextTrigger;
  }

  function popoverIsShowing() {
    if (!supportsNative || !contentElement) return false;
    try {
      return contentElement.matches(":popover-open");
    } catch {
      return false;
    }
  }

  function syncNativePopover() {
    if (!supportsNative || !contentElement) return;
    try {
      if (isOpen && !popoverIsShowing()) {
        contentElement.showPopover({ source: triggerElement });
      } else if (!isOpen && popoverIsShowing()) {
        contentElement.hidePopover();
      }
    } catch {
      // Rapid pointer and focus changes can make requests redundant.
    }
  }

  async function updatePosition() {
    if (!triggerElement?.isConnected || !contentElement?.isConnected) {
      closeNow();
      return;
    }

    const result = await computePosition(triggerElement, contentElement, {
      placement,
      strategy: "fixed",
      middleware: [
        floatingOffset(offset),
        flip(),
        shift({ padding: 8 }),
        floatingArrow({ element: arrowElement, padding: 6 }),
      ],
    });
    const side = result.placement.split("-")[0];
    const staticSide = {
      top: "bottom",
      right: "left",
      bottom: "top",
      left: "right",
    }[side];
    const arrowData = result.middlewareData.arrow ?? {};

    resolvedPlacement = result.placement;
    positionStyle = {
      position: "fixed",
      left: `${result.x}px`,
      top: `${result.y}px`,
    };
    arrowStyle = {
      left: arrowData.x == null ? "" : `${arrowData.x}px`,
      top: arrowData.y == null ? "" : `${arrowData.y}px`,
      right: "",
      bottom: "",
      clipPath: ARROW_CLIP_PATHS[side],
      [staticSide]: `-${ARROW_OVERHANG}px`,
    };
  }

  function startPositioning() {
    cleanupPosition();
    if (!triggerElement || !contentElement) return;
    cleanupPosition = autoUpdate(
      triggerElement,
      contentElement,
      updatePosition,
    );
  }

  function openNow() {
    clearTimeout(openTimer);
    clearTimeout(closeTimer);
    const trigger = syncTrigger();
    if (!trigger || !text) return;

    if (closeActiveTooltip && closeActiveTooltip !== closeNow) {
      closeActiveTooltip();
    }
    closeActiveTooltip = closeNow;
    isOpen = true;
    syncNativePopover();
    queueMicrotask(startPositioning);
  }

  function closeNow() {
    clearTimeout(openTimer);
    clearTimeout(closeTimer);
    cleanupPosition();
    cleanupPosition = () => {};
    isOpen = false;
    syncNativePopover();
    if (closeActiveTooltip === closeNow) closeActiveTooltip = undefined;
  }

  function scheduleOpen() {
    clearTimeout(closeTimer);
    if (isOpen) return;
    clearTimeout(openTimer);
    openTimer = setTimeout(openNow, OPEN_DELAY);
  }

  function scheduleClose() {
    clearTimeout(openTimer);
    clearTimeout(closeTimer);
    closeTimer = setTimeout(closeNow, CLOSE_DELAY);
  }

  function handlePointerOver(event) {
    if (event.pointerType === "touch") return;
    if (contentElement?.contains(event.target)) {
      clearTimeout(closeTimer);
      return;
    }
    scheduleOpen();
  }

  function handlePointerOut(event) {
    if (
      rootElement?.contains(event.relatedTarget) ||
      contentElement?.contains(event.relatedTarget)
    ) {
      return;
    }
    scheduleClose();
  }

  function handlePointerDown(event) {
    if (event.pointerType !== "touch") return;
    lastTouchAt = Date.now();
    closeNow();
  }

  function handleFocusIn() {
    if (Date.now() - lastTouchAt < 1000) return;
    scheduleOpen();
  }

  function handleFocusOut(event) {
    if (rootElement?.contains(event.relatedTarget)) return;
    scheduleClose();
  }

  function handleEscape(event) {
    if (event.key !== "Escape" || !isOpen) return;
    event.preventDefault();
    closeNow();
  }

  function handleContextChange(event) {
    if (!isOpen) return;
    const path = event.composedPath?.() ?? [event.target];
    if (path.includes(triggerElement) || path.includes(contentElement)) return;
    closeNow();
  }

  function styleString(...values) {
    return values
      .flatMap((value) => {
        if (!value) return [];
        if (typeof value === "string") return value;
        return Object.entries(value)
          .filter(([, propertyValue]) => propertyValue != null)
          .map(([property, propertyValue]) => {
            const cssProperty = property.replace(
              /[A-Z]/g,
              (letter) => `-${letter.toLowerCase()}`,
            );
            return `${cssProperty}:${propertyValue}`;
          });
      })
      .join(";");
  }

  onMount(() => {
    supportsNative =
      typeof contentElement?.showPopover === "function" &&
      typeof contentElement?.hidePopover === "function";
    syncTrigger();

    observer = new MutationObserver(syncTrigger);
    observer.observe(rootElement, { childList: true });
    document.addEventListener("keydown", handleEscape);
    document.addEventListener("pointerdown", handleContextChange, true);
    window.addEventListener("blur", closeNow);

    return () => {
      closeNow();
      removeDescription(triggerElement);
      observer.disconnect();
      document.removeEventListener("keydown", handleEscape);
      document.removeEventListener("pointerdown", handleContextChange, true);
      window.removeEventListener("blur", closeNow);
    };
  });
</script>

<span
  bind:this={rootElement}
  role="presentation"
  class="contents"
  onpointerover={handlePointerOver}
  onpointerout={handlePointerOut}
  onpointerdown={handlePointerDown}
  onfocusin={handleFocusIn}
  onfocusout={handleFocusOut}
>
  {@render children?.()}
  <div
    {...contentProps}
    id={tooltipId}
    bind:this={contentElement}
    popover="hint"
    role="tooltip"
    data-slot="tooltip"
    data-state={isOpen ? "open" : "closed"}
    data-placement={resolvedPlacement}
    hidden={!supportsNative && !isOpen}
    class={twMerge(BASE_CLASSES, className)}
    style={styleString(positionStyle, style)}
    onpointerenter={() => clearTimeout(closeTimer)}
    onpointerleave={scheduleClose}
    ontoggle={(event) => {
      if (event.newState === "closed" && isOpen) isOpen = false;
      contentProps.ontoggle?.(event);
    }}
  >
    {text}
    <span
      bind:this={arrowElement}
      aria-hidden="true"
      data-slot="tooltip-arrow"
      class="pointer-events-none absolute size-3 bg-inherit forced-colors:hidden"
      style={styleString(arrowStyle)}
    ></span>
  </div>
</span>

  • Button — supplies truthful button, anchor, and Boring Stack Link semantics.
  • Popover — hosts interactive or structured non-modal content.
  • Menu — presents a keyboard-navigable collection of actions or destinations.

All open source projects are released under the MIT License.