Tooltip
tooltipuiSmall popover triggered by hover/focus, used for short labels — icon-button names, abbreviation expansions, keyboard shortcuts. Auto-positions and respects `prefers-reduced-motion`.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tooltip.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tooltip.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tooltip.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/tooltip.jsonManifest:
https://uipkge.dev/r/svelte/tooltip.jsonInstalls to:src/lib/components/ui/tooltip/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
openControlled open state. Bind it for two-way updates. | boolean | — | optional |
defaultOpenInitial open state for uncontrolled use. | boolean | false | optional |
onOpenChangeCalled whenever the open state changes. | (open: boolean) => void | — | optional |
classtrigger child is full-width, like a sidebar menu button). | string | — | optional |
children | Snippet | — | optional |
Used by
Files installed (6)
src/lib/components/ui/tooltip/Tooltip.svelte1.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface TooltipProps { /** Controlled open state. Bind it for two-way updates. */ open?: boolean /** Initial open state for uncontrolled use. */ defaultOpen?: boolean /** Called whenever the open state changes. */ onOpenChange?: (open: boolean) => void /** Extra classes for the positioning wrapper (e.g. `w-full` when the * trigger child is full-width, like a sidebar menu button). */ class?: string children?: Snippet } </script> <script lang="ts"> import { untrack } from 'svelte' import { cn } from '$lib/utils' import { TooltipRootState, getTooltipProviderState, setTooltipRootState } from './context.svelte' let { defaultOpen = false, open = $bindable(defaultOpen), onOpenChange, class: className, children }: TooltipProps = $props() const root = new TooltipRootState() root.provider = getTooltipProviderState() root.onOpenChange = (next) => { open = next onOpenChange?.(next) } setTooltipRootState(root) // Controlled mode: reflect the bound value. Untracked so internal // trigger/content updates (which already write through to `open`) don't // ping-pong back into the root. $effect(() => { const next = open untrack(() => { if (next !== root.open) root.setOpen(next) }) }) </script> <!-- Relative anchor for the absolutely-positioned content (no portal: the hand-rolled content renders inline and positions against this box). --> <span data-uipkge="" data-slot="tooltip" data-state={root.open ? 'open' : 'closed'} class={cn('relative inline-block', className)}> {@render children?.()} </span>src/lib/components/ui/tooltip/TooltipContent.svelte3.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface TooltipContentProps extends HTMLAttributes<HTMLDivElement> { side?: 'top' | 'right' | 'bottom' | 'left' sideOffset?: number align?: 'start' | 'center' | 'end' alignOffset?: number /** Show the arrow pointer. Default true. */ showArrow?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getTooltipRootState } from './context.svelte' let { class: className, side = 'top', sideOffset = 4, align = 'center', alignOffset = 0, showArrow = true, children, ref = $bindable(null), ...restProps }: TooltipContentProps = $props() const root = getTooltipRootState() const positionClass = $derived.by(() => { switch (side) { case 'top': return align === 'start' ? 'bottom-full left-0' : align === 'end' ? 'bottom-full right-0' : 'bottom-full left-1/2 -translate-x-1/2' case 'bottom': return align === 'start' ? 'top-full left-0' : align === 'end' ? 'top-full right-0' : 'top-full left-1/2 -translate-x-1/2' case 'left': return align === 'start' ? 'right-full top-0' : align === 'end' ? 'right-full bottom-0' : 'right-full top-1/2 -translate-y-1/2' case 'right': return align === 'start' ? 'left-full top-0' : align === 'end' ? 'left-full bottom-0' : 'left-full top-1/2 -translate-y-1/2' } }) const arrowClass = $derived.by(() => { switch (side) { case 'top': return 'bottom-[-3px] left-1/2 -translate-x-1/2' case 'bottom': return 'top-[-3px] left-1/2 -translate-x-1/2' case 'left': return 'right-[-3px] top-1/2 -translate-y-1/2' case 'right': return 'left-[-3px] top-1/2 -translate-y-1/2' } }) const offsetStyle = $derived.by(() => { const main = `${sideOffset}px` const cross = alignOffset ? `${alignOffset}px` : undefined const style: Record<string, string> = {} if (side === 'top') style['margin-bottom'] = main else if (side === 'bottom') style['margin-top'] = main else if (side === 'left') style['margin-right'] = main else style['margin-left'] = main if (cross) { if (side === 'top' || side === 'bottom') style['margin-left'] = cross else style['margin-top'] = cross } return Object.entries(style) .map(([k, v]) => `${k}: ${v}`) .join('; ') }) </script> {#if root?.open} <div bind:this={ref} id={root.id} role="tooltip" data-uipkge="" data-slot="tooltip-content" data-side={side} data-align={align} data-state="open" class={cn( 'bg-foreground text-background motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:data-[state=closed]:animate-out motion-safe:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 absolute z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', positionClass, className, )} style={offsetStyle} {...restProps} > {@render children?.()} {#if showArrow} <div data-slot="tooltip-arrow" aria-hidden="true" class={cn( 'bg-foreground fill-foreground absolute z-50 size-2.5 rotate-45 rounded-[2px]', arrowClass, )} ></div> {/if} </div> {/if}src/lib/components/ui/tooltip/TooltipProvider.svelte0.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface TooltipProviderProps { /** ms before a hovered tooltip opens. Default 700. */ delayDuration?: number /** Window after a close during which the next tooltip skips the delay. Default 300. */ skipDelayDuration?: number children?: Snippet } </script> <script lang="ts"> import { TooltipProviderState, setTooltipProviderState } from './context.svelte' let { delayDuration = 700, skipDelayDuration = 300, children }: TooltipProviderProps = $props() const state = new TooltipProviderState() setTooltipProviderState(state) $effect(() => { state.delayDuration = delayDuration state.skipDelayDuration = skipDelayDuration }) </script> {@render children?.()}src/lib/components/ui/tooltip/TooltipTrigger.svelte2.5 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' type SpanAttrs = HTMLAttributes<HTMLSpanElement> type TriggerPointerEvent = Parameters<NonNullable<SpanAttrs['onpointerenter']>>[0] type TriggerFocusEvent = Parameters<NonNullable<SpanAttrs['onfocus']>>[0] type TriggerKeydownEvent = Parameters<NonNullable<SpanAttrs['onkeydown']>>[0] export interface TooltipTriggerProps extends HTMLAttributes<HTMLSpanElement> { /** Render your own element with the trigger handlers instead of the * default wrapping span — the Svelte counterpart of `asChild`. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLSpanElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getTooltipRootState } from './context.svelte' let { class: className, child, children, ref = $bindable(null), onpointerenter, onpointerleave, onfocus, onblur, onkeydown, ...restProps }: TooltipTriggerProps = $props() const root = getTooltipRootState() const describedBy = $derived(root?.open ? root.id : undefined) function handlePointerEnter(event: TriggerPointerEvent) { onpointerenter?.(event) if (!event.defaultPrevented) root?.scheduleOpen() } function handlePointerLeave(event: TriggerPointerEvent) { onpointerleave?.(event) if (!event.defaultPrevented) root?.cancelScheduledOpen() } function handleFocus(event: TriggerFocusEvent) { onfocus?.(event) if (!event.defaultPrevented) root?.setOpen(true) } function handleBlur(event: TriggerFocusEvent) { onblur?.(event) if (!event.defaultPrevented) root?.setOpen(false) } function handleKeydown(event: TriggerKeydownEvent) { onkeydown?.(event) if (event.key === 'Escape' && root?.open) root.setOpen(false) } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'tooltip-trigger', 'data-state': root?.open ? 'open' : 'closed', 'aria-describedby': describedBy, class: cn('inline-block', className), onpointerenter: handlePointerEnter, onpointerleave: handlePointerLeave, onfocus: handleFocus, onblur: handleBlur, onkeydown: handleKeydown, ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <!-- The wrapper span lets tooltips fire on disabled controls inside it. Positioning anchors to the Tooltip root, not this span. --> <span bind:this={ref} {...mergedProps}> {@render children?.()} </span> {/if}src/lib/components/ui/tooltip/context.svelte.ts2 kB
import { getContext, setContext } from 'svelte' let tooltipCounter = 0 export class TooltipProviderState { delayDuration = $state(700) skipDelayDuration = $state(300) /** Timestamp of the last tooltip close, for skip-delay behavior. */ lastCloseAt = $state(0) } const TOOLTIP_PROVIDER_KEY = Symbol('TooltipProvider') export function setTooltipProviderState(state: TooltipProviderState) { setContext(TOOLTIP_PROVIDER_KEY, state) } export function getTooltipProviderState(): TooltipProviderState | undefined { return getContext<TooltipProviderState | undefined>(TOOLTIP_PROVIDER_KEY) } export class TooltipRootState { id = `tooltip-${++tooltipCounter}` open = $state(false) provider: TooltipProviderState | undefined = undefined onOpenChange: ((open: boolean) => void) | undefined = undefined private openTimer: ReturnType<typeof setTimeout> | null = null setOpen(next: boolean) { this.clearTimer() if (this.open === next) return if (!next && this.provider) this.provider.lastCloseAt = Date.now() this.open = next this.onOpenChange?.(next) } /** Hover entry: honor the provider delay unless another tooltip just closed. */ scheduleOpen() { this.clearTimer() if (this.open) return const delay = this.provider?.delayDuration ?? 700 const skip = this.provider?.skipDelayDuration ?? 300 if (delay <= 0 || Date.now() - (this.provider?.lastCloseAt ?? 0) < skip) { this.setOpen(true) return } this.openTimer = setTimeout(() => this.setOpen(true), delay) } cancelScheduledOpen() { this.clearTimer() this.setOpen(false) } clearTimer() { if (this.openTimer) { clearTimeout(this.openTimer) this.openTimer = null } } } const TOOLTIP_ROOT_KEY = Symbol('TooltipRoot') export function setTooltipRootState(state: TooltipRootState) { setContext(TOOLTIP_ROOT_KEY, state) } export function getTooltipRootState(): TooltipRootState | undefined { return getContext<TooltipRootState | undefined>(TOOLTIP_ROOT_KEY) }src/lib/components/ui/tooltip/index.ts0.3 kB
export { default as Tooltip, type TooltipProps } from './Tooltip.svelte' export { default as TooltipContent, type TooltipContentProps } from './TooltipContent.svelte' export { default as TooltipProvider, type TooltipProviderProps } from './TooltipProvider.svelte' export { default as TooltipTrigger, type TooltipTriggerProps } from './TooltipTrigger.svelte'
Raw manifest:https://uipkge.dev/r/svelte/tooltip.json