Frontile

Tooltip

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

import { Tooltip } from 'frontile';

Usage

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>

Placement

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

@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>

Rich content

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>

Color

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>

Size

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>

Delays

@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>

Interactive content

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>

Controlled

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>
}

Imperative Controls

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>

Disabled

@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>

Accessibility

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.

API

Tooltip

Element: HTMLDivElement

Arguments

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.

Blocks

Name Type Default Description
default * Array -
Released under MIT License - Created by Josemar Luedke