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.
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.
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
<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
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
<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
| Input | Default | Purpose |
|---|---|---|
text | required | Short, non-interactive supplementary text. |
placement | top | Preferred top, right, bottom, or left side; may flip to fit. |
offset | 8 | Pixel distance between the trigger and tooltip. |
class / className | — | Ordinary Tailwind classes merged last on the tooltip surface. |
| default child | required | One 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-labelor 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.
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
<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
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
<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
<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
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
<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>