Tooltip

Accessible hover and focus tooltip.

Live demo

01

Four placements

Keyboard accessible

Tab to focus the button β€” the tooltip appears. Press Esc to dismiss while focused.

Custom delays

On an icon button

Rich content via snippet

Implementation

02
Tooltip.svelte
<script lang="ts">
  import Tooltip from '$lib/components/Tooltip.svelte';
</script>
​
<Tooltip text="Helpful hint" placement="top" showDelay={300}>
  <button>Hover me</button>
</Tooltip>

Tooltip wraps any focusable trigger and links the tooltip surface via aria-describedby, so screen readers announce the tip alongside the trigger's own label rather than replacing it. It listens for both pointer and focus events (so keyboard users see the tip when tabbing in) and dismisses on Escape while a tip is visible. Pass plain text via the text prop, or a tip snippet for rich content like keyboard hints.

Logic explainer

03

What Does It Do? (Plain English)

Wraps any trigger element β€” a button, link, or icon β€” and shows a small floating panel of helpful text when the user hovers over it or focuses it with the keyboard. The panel has an arrow pointing back at the trigger, so the connection is unambiguous even when several tooltips are visible. Press Escape (or move the pointer away) and it disappears.

Think of it as a labelled sticky note that appears just long enough to answer "what does this do?" then steps out of the way. Crucially, it works for keyboard users too β€” focus alone triggers it, not just mouse hover.

How It Works (Pseudo-Code)

state:
  visible    = false
  showTimer  = null
  hideTimer  = null
  tooltipId  = id ?? auto-generated

events:
  on pointer enter trigger:
    cancel hideTimer
    schedule showTimer = after showDelay β†’ visible = true

  on pointer leave trigger:
    cancel showTimer
    schedule hideTimer = after hideDelay β†’ visible = false

  on focus trigger:                      // keyboard / programmatic
    visible = true (immediate, no delay)

  on blur trigger:
    visible = false (immediate)

  on keydown Escape (anywhere on trigger or tooltip):
    visible = false
    cancel both timers

derived markup:
  trigger gets   aria-describedby = tooltipId   (only when visible)
  tooltip body   role="tooltip" id=tooltipId    (only mounted when visible)
                                                 positioned by placement prop

The trigger is wrapped in a positioned wrapper; the tooltip is an absolutely-positioned sibling, so it floats out of normal flow without affecting layout. The CSS arrow is a triangle drawn with border tricks on a pseudo-element β€” no SVG, no extra DOM.

Positioning Algorithm

Placement is purely declarative β€” the placement prop selects one of four pre-baked CSS rules. Each sets the tooltip's top/bottom/left/right relative to the wrapper, plus a transform to centre on the cross-axis:

placement = 'top'     bottom: 100%; left: 50%;  transform: translate(-50%, -8px);
placement = 'bottom'  top: 100%;    left: 50%;  transform: translate(-50%,  8px);
placement = 'left'    right: 100%;  top: 50%;   transform: translate(-8px, -50%);
placement = 'right'   left: 100%;   top: 50%;   transform: translate( 8px, -50%);

The 8px offset gives the arrow room between trigger and body. The arrow itself is a ::after pseudo-element on .tooltip-body; its border-color is set so three sides are transparent and the fourth points back at the trigger.

This is deliberately static positioning β€” no Floating UI, no popper, no auto-flip on viewport edges. Trade-off: at the screen's literal corners, a tooltip can clip. We accept this for a 2KB component; pages where edge-clipping matters should use a Floating UI integration or MorphingDialog instead.

Accessibility Deep-Dive

Three architectural choices make this work for screen-reader and keyboard users:

  1. aria-describedby, not aria-labelledby. The trigger keeps its own accessible name (its visible label or aria-label). The tooltip is supplemental β€” answering "any extra detail?" β€” which is exactly what describedby semantics carry.

  2. Focus triggers, not just hover. The same handlers that fire on pointerenter / pointerleave also fire on focus / blur. A keyboard user tabbing to the trigger sees the tooltip; a mouse user hovering sees the same thing.

  3. Escape closes everywhere. Even if focus is inside the tooltip body (rich tip snippet with a link), Escape clears visible immediately and cancels any pending show timer. Matches modal-dismissal expectations.

The tooltip element is only mounted when visible === true β€” it doesn't sit in the DOM with display: none. That keeps the screen-reader tree small and avoids stale aria-describedby references when the tooltip isn't being shown.

CSS Animation Strategy

A short transform + opacity fade frames the appearance. Pure CSS β€” no JS animation library:

.tooltip-body {
  opacity: 0;
  transform: translate(...) scale(0.96);
  transition: opacity 120ms ease, transform 120ms ease;
}
.tooltip-body[data-visible='true'] {
  opacity: 1;
  transform: translate(...) scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .tooltip-body { transition: none; }
}

scale(0.96) β†’ scale(1) is too small to read as motion but lifts the body subtly off the trigger β€” the eye reads it as "popping forward". transform + opacity are compositor-only, so the animation can't trigger layout.

State Flow Diagram

                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚     hidden     β”‚  ← initial
                β”‚  visible=false β”‚
                β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”˜
                   β”‚           β”‚
      hover / focusβ”‚           β”‚ (hideTimer fires)
                   β–Ό           β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
              β”‚ pending  β”‚     β”‚
              β”‚ showTimerβ”‚     β”‚
              β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜     β”‚
                   β”‚ delay     β”‚
                   β–Ό           β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
              β”‚   visible   β”‚β”€β”€β”˜
              β”‚ aria-       β”‚
              β”‚ describedby β”‚
              β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚ leave / blur / Esc
                    β–Ό
                hideTimer  β†’  hidden

Props Reference

Prop Type Default Description
text string '' Plain-text tooltip body. Ignored if tip snippet is supplied.
placement 'top' | 'right' | 'bottom' | 'left' 'top' Side of the trigger the tooltip sits on.
showDelay number 200 Milliseconds before showing on hover. Focus is always immediate.
hideDelay number 0 Milliseconds before hiding after leave. Useful when the tooltip body is interactive.
id string auto Custom id for the tooltip element (auto-generated otherwise β€” used for aria-describedby).
class string '' Extra classes appended to the wrapper.
children Snippet required The trigger element to wrap.
tip Snippet β€” Rich-content body (overrides text). Use for inline <strong>, <kbd>, etc.

Edge Cases

Situation Behaviour
Trigger sits at the right or bottom edge of the viewport Tooltip may clip outside the viewport. Use a different placement or wrap in a container with overflow: visible β€” this component does not auto-flip.
User has prefers-reduced-motion: reduce The fade/scale transition is dropped; show/hide becomes an instant swap.
User hovers the trigger and quickly tabs away Hover handler cleared on blur; tooltip never appears. No flash.
User focuses the trigger and immediately presses Escape visible flips to false, both timers cancelled. Trigger keeps focus.
Tooltip body itself contains a focusable element (rich tip) Tab moves into the body. Blur on the trigger doesn't fire while focus is inside, so hideDelay > 0 is required to let the user move there.
Multiple Tooltips on one page Each gets its own auto-generated id. aria-describedby references are unique.
Trigger removed from DOM while tooltip is visible Tooltip unmounts with the wrapper; no orphaned panel.
Page navigates while a tooltip is open SvelteKit unmounts the route; the tooltip goes with it.

Dependencies

  • Svelte 5.x β€” runes ($state, $derived) and snippets are core to the implementation.
  • Zero external dependencies β€” pure CSS for panel, arrow, and animation.

File Structure

src/lib/components/Tooltip.svelte         # implementation
src/lib/components/Tooltip.md             # this file (rendered inside ComponentPageShell)
src/lib/components/Tooltip.test.ts        # unit tests
src/routes/tooltip/+page.svelte           # demo page
docs/THEMING.md                           # the (0,2,0) specificity override mechanism

API

04
PropTypeDefaultDescription
textstring''Plain-text tooltip body. Ignored if tip snippet is provided.
placement'top' | 'right' | 'bottom' | 'left''top'Where the tooltip appears relative to the trigger.
showDelaynumber200Delay before the tooltip appears (ms).
hideDelaynumber0Delay before the tooltip hides on blur/leave.
idstringautoOverride the generated id used in aria-describedby.
tipSnippetβ€”Render rich tooltip content instead of plain text.
childrenSnippetβ€”The trigger element to wrap.