tooltip

One recycled tooltip node serves the whole document, rendered in the browser’s top layer through the Popover API by default; fixed and absolute remain explicit positioning strategies. The defaults follow intent rather than raw events: keyboard focus opens immediately, pointer hover waits 100 ms, the pointer may travel onto the tooltip without closing it, Escape dismisses, touch-generated hover is ignored, and moving to a nearby trigger within 300 ms skips the opening delay.

  • Content: content or a per-trigger content(trigger). Without explicit content, title, aria-label, then data-title are read, aria-label only for icon-only triggers (a trigger showing its own text, or a checkbox/radio beside its label, keeps it as its accessible name). Attaching once to a container delegates to matching descendants; delegate takes a selector. Text that only repeats the trigger’s own visible text is skipped, unless the trigger or a descendant overflows (e.g. an ellipsis), where the tooltip reveals the hidden part.
  • Position: placement defaults to auto. align, fallback_placements, offset, cross_axis_offset, viewport_padding, boundary, flip and shift refine it, and the tooltip tracks scrolling, resizing and content changes.
  • Interaction: trigger, touch_focus, open_delay_ms, close_delay_ms and skip_delay_ms; on_open_change reports visibility changes caused by hover, focus, Escape, or teardown.
  • Rendering: content is plain text, wrap is balance, normal or nowrap, and show_arrow defaults to true.
  • Styling: style for one-off declarations, or theme with --tooltip-bg, --text-color, --tooltip-border, --tooltip-padding, --tooltip-radius, --tooltip-font-size, --tooltip-font-family, --tooltip-shadow, --tooltip-max-width, --tooltip-max-height, --tooltip-opacity, --tooltip-arrow-size, --tooltip-transition and --tooltip-z-index.

Placement

auto measures the free space on every side and takes the roomiest, so a trigger near the bottom of the window puts its tooltip above without being told to. Naming a side pins it, though the tooltip still slides along the viewport edge rather than overflowing — and the arrow keeps pointing at the trigger after that slide. align moves it along the trigger’s cross axis, offset sets the gap, and fallback_placements restricts which sides auto may pick from.

svelte<script lang="ts">
  import { tooltip } from 'svelte-widgets/attachments'
  import type { Placement } from 'svelte-widgets/utils'

  let placement = $state<Placement | `auto`>(`auto`)
  let align = $state<`start` | `center` | `end`>(`center`)
  let offset = $state(12)
  let scroll_boundary = $state<HTMLElement>()
</script>

<div style="display: flex; gap: 1em; align-items: end; flex-wrap: wrap">
  <label>
    placement
    <select bind:value={placement}>
      {#each [`auto`, `top`, `right`, `bottom`, `left`] as side (side)}
        <option>{side}</option>
      {/each}
    </select>
  </label>
  <label>
    align
    <select bind:value={align}>
      {#each [`start`, `center`, `end`] as mode (mode)}<option>{mode}</option>{/each}
    </select>
  </label>
  <label>
    offset
    <input type="number" min="0" max="40" bind:value={offset} style="width: 5em" />
  </label>
</div>

<div
  style="box-sizing: border-box; width: 100%; margin-inline: auto; border: 1px solid gray; border-radius: 5pt; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3); display: grid; place-items: center; height: 9em; margin-top: 1em"
>
  <button
    {@attach tooltip({
      content: `placement: ${placement} · align: ${align}`,
      placement,
      align,
      offset,
    })}
  >
    Hover the target
  </button>
</div>

<!-- scrolling starves one side, flipping the tooltip; padding keeps the button visible -->
<div
  bind:this={scroll_boundary}
  style="box-sizing: border-box; width: 100%; margin-inline: auto; border: 1px solid gray; border-radius: 5pt; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3); height: 9em; overflow: auto; margin-top: 1em"
>
  <div style="padding: 5em 0; display: grid; place-items: center">
    <button
      {@attach tooltip({
        content: `I flip as you scroll`,
        boundary: scroll_boundary,
        placement: `bottom`,
        trigger: `focus`,
      })}
    >
      Click, then scroll me past the edges
    </button>
  </div>
</div>

Content sources

Attach tooltip() once to a container and every descendant carrying title, data-title or, if icon-only, aria-label gets its own tooltip — including elements rendered later, since one delegated listener pair covers the whole subtree. The original title is stripped while the tooltip owns it, so the native yellow box never doubles up, and restored on teardown. delegate narrows delegation to a selector and content(trigger) derives the text per element.

svelte<script lang="ts">
  import { Icon } from 'svelte-widgets'
  import { tooltip } from 'svelte-widgets/attachments'
  import { Info } from 'svelte-widgets/icons'

  const all_planets = [`Mercury`, `Venus`, `Earth`, `Mars`, `Jupiter`, `Saturn`]
  let shown = $state(3)
</script>

<!-- One attachment, three content sources; aria-label only feeds icon-only triggers -->
<div style="display: flex; gap: 1em; flex-wrap: wrap" {@attach tooltip()}>
  <button title="Read from the title attribute">title</button>
  <button aria-label="Read from aria-label"
    ><Icon icon={Info} aria-hidden="true" /></button
  >
  <button data-title="Read from data-title">data-title</button>
</div>

<!-- A delegate selector plus content(trigger): rows added later are covered too -->
<div
  style="display: flex; gap: 1em; margin-top: 1em; flex-wrap: wrap; align-items: center"
  {@attach tooltip({
    delegate: `[data-planet]`,
    content: (trigger) => `Planet ${trigger.dataset.planet} of the solar system`,
  })}
>
  {#each all_planets.slice(0, shown) as planet, idx (planet)}
    <button data-planet={idx + 1}>{planet}</button>
  {/each}
  {#if shown < all_planets.length}
    <button onclick={() => (shown += 1)}>Add a planet</button>
  {/if}
</div>

Content and interaction

Tooltip content is plain text. The surface stays open while hovered so its text can be selected. Use Popover for formatted content, controls, or application-controlled open state.

trigger picks hover-focus (the default), hover, or focus. open_delay_ms applies to pointer hover only; focus opens immediately. skip_delay_ms skips that delay when moving between neighbouring triggers. on_open_change reports visibility changes and their cause.

With hover-focus, touch-induced focus is ignored unless touch_focus: true is set. This allows a focusable trigger to show help after a tap while retaining desktop hover behavior. focus always accepts touch-induced focus; hover never does. Touch-generated hover remains ignored in every mode.

last reason: —
svelte<script lang="ts">
  import { tooltip } from 'svelte-widgets/attachments'

  let [open_delay_ms, close_delay_ms] = $state([100, 100])
  let last_reason = $state(`—`)
</script>

<div style="display: flex; gap: 1em; align-items: end; flex-wrap: wrap">
  <label>
    open_delay_ms
    <input type="number" min="0" step="50" bind:value={open_delay_ms} size="4" />
  </label>
  <label>
    close_delay_ms
    <input type="number" min="0" step="50" bind:value={close_delay_ms} size="4" />
  </label>
</div>

<div
  style="display: flex; gap: 1em; margin-top: 1em; flex-wrap: wrap; align-items: center"
>
  <button
    {@attach tooltip({
      content: `A pointer opens this, Tab does not`,
      trigger: `hover`,
      open_delay_ms,
      close_delay_ms,
    })}
  >
    hover only
  </button>
  <button {@attach tooltip({ content: `Tab here — no delay`, trigger: `focus` })}>
    focus only
  </button>
  <button
    {@attach tooltip({
      content: `Reports focus, pointer, and Escape changes`,
      on_open_change: (_open, detail) => {
        last_reason = detail.reason
      },
    })}
  >
    Visibility events
  </button>
  <small>last reason: <code>{last_reason}</code></small>
</div>

Styling

Every CSS variable is read off the trigger, so a tooltip can be themed by the element it belongs to rather than globally. style appends one-off declarations, and show_arrow: false drops the arrow and its border triangle.

svelte<script lang="ts">
  import { tooltip } from 'svelte-widgets/attachments'
</script>

<div style="display: flex; gap: 1em; flex-wrap: wrap">
  <button
    style="--tooltip-bg: #0f2a43; --text-color: #d7ecff; --tooltip-border: 1px solid rgba(0, 128, 255, 0.4); --tooltip-shadow: drop-shadow(0 4px 12px rgba(0, 128, 255, 0.25)); --tooltip-arrow-size: 8px"
    {@attach tooltip({ content: `Themed through CSS variables`, placement: `top` })}
  >
    Info theme
  </button>
  <button
    style="--tooltip-bg: #2d3748; --text-color: #e2e8f0; --tooltip-border: 2px solid #4299e1; --tooltip-arrow-size: 8px"
    {@attach tooltip({
      content: `A thick border gets its own arrow triangle behind the fill`,
      placement: `top`,
      style: `box-shadow: 0 10px 25px rgba(66, 153, 225, 0.3); font-weight: bold;`,
    })}
  >
    Border arrow
  </button>
  <button {@attach tooltip({ content: `No arrow on this one`, show_arrow: false })}>
    show_arrow: false
  </button>
  <button {@attach tooltip({ content: `Never rendered`, disabled: true })}>
    disabled
  </button>
</div>

Wrapping and long words

Width resolves to min(--tooltip-max-width, viewport − padding), so a tooltip can never be wider than the screen it sits on. wrap selects balance (the default, for even line lengths), normal or nowrap, and overflow-wrap: anywhere breaks a word that would otherwise push the box off screen.

svelte<script lang="ts">
  import { tooltip } from 'svelte-widgets/attachments'
</script>

<div style="display: flex; gap: 1em; flex-wrap: wrap">
  <button
    style="--tooltip-max-width: 200px"
    {@attach tooltip({
      content: `This tooltip uses balanced text wrapping for even line lengths`,
      placement: `top`,
      wrap: `balance`,
    })}
  >
    Balanced wrapping
  </button>
  <button
    {@attach tooltip({
      content: `This one never wraps, however far it runs`,
      placement: `top`,
      wrap: `nowrap`,
    })}
  >
    No wrapping
  </button>
  <button
    style="--tooltip-max-width: 220px"
    {@attach tooltip({
      content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`,
      placement: `bottom`,
    })}
  >
    Long text viewport-safe
  </button>
  <button
    {@attach tooltip({
      content: `Donaudampfschifffahrtsgesellschaftskapitän is a German compound word`,
      placement: `top`,
    })}
  >
    Long German word
  </button>
  <button
    {@attach tooltip({ content: `antidisestablishmentarianism`, placement: `left` })}
  >
    Single long word
  </button>
</div>

Reactive content

A MutationObserver watches the trigger, so a tooltip already on screen re-renders and repositions when its title, aria-label or data-title changes — and when the trigger’s own styling changes, the surface picks up the new theme with it.

svelte<script lang="ts">
  import { tooltip } from 'svelte-widgets/attachments'
  let text = $state(`Edit me!`)
</script>

<input bind:value={text} style="width: 16ch" />
<button title={text} {@attach tooltip({ placement: `right` })}>Hover me</button>