A Tooltip shows a short, non-interactive hint next to whatever a user hovers or
keyboard-focuses. It is built on
Popover
: reach for Popover
instead whenever the overlay needs to hold links, buttons, or anything else a
user must be able to interact with, because a tooltip's content is announced
through aria-describedby as a description of the trigger, not a region of its
own — interactive elements inside it are unreachable to assistive technology.
import { Tooltip } from 'frontile';
Pass @content for the common case of a plain-text tooltip, and put the
trigger modifier on the element the tooltip describes.
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
<template>
<Tooltip @content='Add to library' as |t|>
<Button {{t.trigger}}>Add</Button>
</Tooltip>
</template>
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
const placements = ['top', 'right', 'bottom', 'left'];
<template>
<div class='flex flex-wrap gap-8 p-8'>
{{#each placements as |placement|}}
<Tooltip @content={{placement}} @placement={{placement}} as |t|>
<Button {{t.trigger}}>{{placement}}</Button>
</Tooltip>
{{/each}}
</div>
</template>
@arrow={{true}} renders an arrow pointing at the trigger.
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
<template>
<Tooltip @content='With an arrow' @arrow={{true}} as |t|>
<Button {{t.trigger}}>Hover me</Button>
</Tooltip>
</template>
Use the yielded Content block instead of @content when the tooltip needs
more than a single string. Passing both @content and a Content block
asserts in development — pick one.
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
import { Kbd } from 'frontile';
<template>
<Tooltip as |t|>
<Button {{t.trigger}}>Keyboard shortcuts</Button>
<t.Content>
<p class='font-semibold'>Save</p>
<p class='flex items-center gap-1'>
<Kbd @keys='mod+s' @variant='inherit' @size='sm' />
saves the current document.
</p>
</t.Content>
</Tooltip>
</template>
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
const intents = [
'default',
'primary',
'secondary',
'tertiary',
'success',
'warning',
'danger'
];
<template>
<div class='flex flex-wrap gap-8 p-8'>
{{#each intents as |intent|}}
<Tooltip @content={{intent}} @color={{intent}} as |t|>
<Button {{t.trigger}}>{{intent}}</Button>
</Tooltip>
{{/each}}
</div>
</template>
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
const sizes = ['sm', 'md', 'lg'];
<template>
<div class='flex flex-wrap gap-8 p-8'>
{{#each sizes as |size|}}
<Tooltip @content={{size}} @size={{size}} as |t|>
<Button {{t.trigger}}>{{size}}</Button>
</Tooltip>
{{/each}}
</div>
</template>
@openDelay (default 200ms) and @closeDelay (default 150ms) control how
long the tooltip waits before showing and hiding. @closeDelay is also the
window the pointer has to cross the gap between the trigger and the tooltip
content, so setting it very low makes the tooltip effectively non-interactive.
These defaults are longer than the underlying Popover's hover defaults
(100ms/100ms): a tooltip fires on every incidental mouse pass over its
trigger, so it waits a little longer before appearing to avoid flashing at a
user who was only moving the cursor across.
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
<template>
<Tooltip
@content='Opens slowly, closes fast'
@openDelay={{600}}
@closeDelay={{0}}
as |t|
>
<Button {{t.trigger}}>Hover and wait</Button>
</Tooltip>
</template>
By default, moving the pointer or keyboard focus off the trigger and onto the
tooltip's own content keeps it open — useful when the content is long enough
that a reader's cursor has to cross a gap to reach it. @disableInteractive
closes the tooltip as soon as the pointer leaves the trigger instead, without
waiting to see whether it lands on the content.
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
<template>
<div class='flex flex-wrap gap-8 p-8'>
<Tooltip @content='You can hover this tooltip' as |t|>
<Button {{t.trigger}}>Interactive (default)</Button>
</Tooltip>
<Tooltip
@content='This one closes immediately'
@disableInteractive={{true}}
as |t|
>
<Button {{t.trigger}}>Not interactive</Button>
</Tooltip>
</div>
</template>
Pair @isOpen with @onOpenChange to drive the tooltip from your own state.
@isOpen only takes effect together with @onOpenChange — passing @isOpen
alone falls back to uncontrolled behavior.
Open the tooltip in response to an interaction or after the component has
mounted, not on the very first render: the trigger installs its floating-ui
anchor through a modifier, which only runs once the element exists, so forcing
@isOpen={{true}} before that happens throws.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
export default class ControlledTooltipExample extends Component {
@tracked isOpen = false;
onOpenChange = (isOpen: boolean) => {
this.isOpen = isOpen;
};
show = () => {
this.isOpen = true;
};
<template>
<Button @onPress={{this.show}} class='mr-4'>Show tooltip</Button>
<Tooltip
@content='Opened from outside'
@isOpen={{this.isOpen}}
@onOpenChange={{this.onOpenChange}}
as |t|
>
<Button {{t.trigger}}>Trigger</Button>
</Tooltip>
</template>
}
Besides @isOpen/@onOpenChange, the default block also yields isOpen, open, and
close directly, with no external state required. open and close drive the tooltip from
anywhere on the page — a button doesn't have to be the trigger itself — and isOpen reports
whether the tooltip is currently open.
Closed
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
<template>
<Tooltip @content='Hover me, then dismiss me from the button' as |t|>
<Button {{t.trigger}} class='mr-4'>Trigger</Button>
<Button @variant='outline' @onPress={{t.close}}>Close tooltip</Button>
<p class='mt-2 text-sm'>{{if t.isOpen 'Open' 'Closed'}}</p>
</Tooltip>
</template>
@isDisabled={{true}} still installs the trigger element, but skips wiring up the hover and
focus listeners that open the tooltip — so the tooltip never opens, and the trigger itself
renders exactly as you wrote it, whether or not it also carries a plain disabled attribute.
Toggling @isDisabled back to false at runtime re-installs the listeners immediately.
The common case is disabling the tooltip alongside a disabled trigger, so a hint about an unavailable action doesn't pop up on hover.
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
<template>
<div class='flex flex-wrap gap-8 p-8'>
<Tooltip @content='Save your changes' @isDisabled={{true}} as |t|>
<Button {{t.trigger}} disabled>Save (disabled)</Button>
</Tooltip>
<Tooltip @content='Save your changes' as |t|>
<Button {{t.trigger}}>Save (enabled)</Button>
</Tooltip>
</div>
</template>
The trigger modifier marks up the element it's applied to as the tooltip's
description, and keeps it in sync as the tooltip opens and closes:
aria-describedby="<the content's id>"
The tooltip opens on mouse hover, and on keyboard focus-visible after
@openDelay. Escape closes it while it's open. The content itself carries
role="tooltip" and is never a tab stop (tabindex="-1") — focus is never
moved into it, only the description relationship changes.
Touch has no equivalent: there is no long-press emulation, so anything said only in a tooltip is invisible to a touch-only user. Make sure the same information is available another way — in the trigger's own label, or elsewhere on the page — rather than relying on the tooltip alone.
Element: HTMLDivElement
| Name | Type | Default | Description |
|---|---|---|---|
arrow
|
boolean
|
false
|
Renders an arrow pointing at the trigger. |
class
|
string
|
- | Custom class for the tooltip, merged with the theme's using Tailwind Merge. |
classes
|
SlotsToClasses<'base' | 'arrow'>
|
- | Class names for each slot, merged with the theme's. |
closeDelay
|
number
|
150
|
Milliseconds before closing. Also the window the pointer has to cross the gap into the tooltip, so a very small value makes the tooltip effectively non-interactive. |
color
|
enum
|
'neutral'
|
The tooltip's semantic color role. The arrow inherits the body's background, so it follows the chosen intent automatically. |
content
|
string
|
- |
The tooltip's text. A shorthand for the common case; pass a Content
block instead when the tooltip needs markup. Passing both asserts.
|
didClose
|
function
|
- | Callback when closing has finished, including any exit transition. |
disableInteractive
|
boolean
|
false
|
Closes as soon as the pointer leaves the trigger, rather than letting it move onto the tooltip. |
flipOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean | 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' | 'initialPlacement'; fallbackAxisSideDirection?: 'start' | ... 1 more ... | 'none'; ... 4 more ...; boundary?: Boundary; }
|
- |
Options for the floating-ui flip middleware, which moves the tooltip
to the opposite side when it would overflow the viewport. Forwarded
to the underlying Popover.
|
isDisabled
|
boolean
|
false
|
Installs the trigger but never opens. For a tooltip whose text is conditionally irrelevant. |
isOpen
|
boolean
|
- |
Whether the tooltip is open. Pair with onOpenChange to control it;
leave it unset to let the tooltip manage its own state. Passing
isOpen alone, without onOpenChange, falls back to uncontrolled
behavior.
|
middleware
|
Array
|
- |
Additional floating-ui middleware, for positioning behavior beyond
what placement, offsetOptions, flipOptions, and shiftOptions
cover. Forwarded to the underlying Popover.
|
offsetOptions
|
enum
|
8
|
The gap, in pixels, between the trigger and the tooltip. The default
leaves room for the arrow when @arrow is enabled.
|
onOpenChange
|
function
|
- | Callback when the tooltip opens or closes, receiving the new state. |
openDelay
|
number
|
200
|
Milliseconds before opening on hover or keyboard focus. |
placement
|
enum
|
'top'
|
The requested placement of the tooltip relative to its trigger. This is a preference, not a guarantee: the flip middleware may resolve it to the opposite side when there is no room for it in the requested spot. |
shiftOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; limiter?: { ...; }; boundary?: Boundary; }
|
- |
Options for the floating-ui shift middleware, which nudges the
tooltip along its axis to keep it in view. Forwarded to the
underlying Popover.
|
size
|
enum
|
'md'
|
The tooltip's size, which scales its padding and label text. |
strategy
|
enum
|
'absolute'
|
The CSS positioning strategy, forwarded to the underlying
Popover/floating-ui.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |