Tooltip
Accessible hover and focus tooltip.
Live demo
01Four 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<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
03What 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 propThe 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:
aria-describedby, notaria-labelledby. The trigger keeps its own accessible name (its visible label oraria-label). The tooltip is supplemental β answering "any extra detail?" β which is exactly whatdescribedbysemantics carry.Focus triggers, not just hover. The same handlers that fire on
pointerenter/pointerleavealso fire onfocus/blur. A keyboard user tabbing to the trigger sees the tooltip; a mouse user hovering sees the same thing.Escape closes everywhere. Even if focus is inside the tooltip body (rich
tipsnippet with a link), Escape clearsvisibleimmediately 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 β hiddenProps 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 mechanismAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
text | string | '' | Plain-text tooltip body. Ignored if tip snippet is provided. |
placement | 'top' | 'right' | 'bottom' | 'left' | 'top' | Where the tooltip appears relative to the trigger. |
showDelay | number | 200 | Delay before the tooltip appears (ms). |
hideDelay | number | 0 | Delay before the tooltip hides on blur/leave. |
id | string | auto | Override the generated id used in aria-describedby. |
tip | Snippet | β | Render rich tooltip content instead of plain text. |
children | Snippet | β | The trigger element to wrap. |