FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Tooltip

The WAI-ARIA Tooltip pattern on the platform: the popup lives in the top layer (Popover API), is placed with CSS Anchor Positioning and animates with @starting-style. The library's JavaScript is a thin shim for hover and focus that steps aside where the browser can do the job.

Bold
Italic
Saves the document (Ctrl+S)
interestfor + popover="hint"
Show code
TooltipToolbar.tsx
// No "use client": this file is a Server Component. Tooltip has no action props, so every prop
// is serialisable and a Server Component renders it directly.
import { Tooltip } from "@funstack/ui/tooltip";

function BoldIcon() {
  return (
    <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
      <path
        d="M4 2h5a3 3 0 0 1 2.1 5.1A3.3 3.3 0 0 1 9.5 14H4V2Zm2 2v3h3a1.5 1.5 0 0 0 0-3H6Zm0 5v3h3.5a1.5 1.5 0 0 0 0-3H6Z"
        fill="currentColor"
      />
    </svg>
  );
}

function ItalicIcon() {
  return (
    <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
      <path d="M6 2h7v2h-2.6l-2.3 8H10v2H3v-2h2.6l2.3-8H6V2Z" fill="currentColor" />
    </svg>
  );
}

export function TooltipToolbar() {
  return (
    <div className="demo-row">
      <div className="demo-toolbar" role="toolbar" aria-label="Formatting">
        {/* Icon-only buttons: the tooltip text is the button's name (aria-labelledby). */}
        <Tooltip.Root provides="name">
          <Tooltip.Trigger className="demo-icon-button">
            <BoldIcon />
          </Tooltip.Trigger>
          <Tooltip.Popup className="demo-tip">Bold</Tooltip.Popup>
        </Tooltip.Root>
        <Tooltip.Root provides="name">
          <Tooltip.Trigger className="demo-icon-button">
            <ItalicIcon />
          </Tooltip.Trigger>
          <Tooltip.Popup className="demo-tip">Italic</Tooltip.Popup>
        </Tooltip.Root>
      </div>
      {/* A button with its own text: the tooltip is a description (aria-describedby). */}
      <Tooltip.Root>
        <Tooltip.Trigger className="demo-button">Save</Tooltip.Trigger>
        <Tooltip.Popup className="demo-tip" side="bottom" align="start">
          Saves the document (Ctrl+S)
        </Tooltip.Popup>
      </Tooltip.Root>
      {/* Chromium: the browser owns hover, focus and dismissal. Elsewhere: the JS path. */}
      <Tooltip.Root delay={400}>
        <Tooltip.Trigger className="demo-button" experimental_interestFor>
          Native where supported
        </Tooltip.Trigger>
        <Tooltip.Popup className="demo-tip" side="right" experimental_hint>
          interestfor + popover=&quot;hint&quot;
        </Tooltip.Popup>
      </Tooltip.Root>
    </div>
  );
}
tooltip.css
/* Tooltip: open state belongs to the browser, so style it with :popover-open and animate it
   with @starting-style. The library ships no CSS. */
.demo-tip {
  margin: 6px; /* gap from the trigger; overrides the UA's margin: auto */
  padding: 4px 8px;
  border: 0;
  border-radius: 4px;
  background: var(--color-text);
  color: var(--color-bg);
  font-size: 0.875rem;
  max-inline-size: 24rem;
  opacity: 0;
  translate: 0 4px;
  transition:
    opacity 120ms,
    translate 120ms,
    display 120ms allow-discrete,
    overlay 120ms allow-discrete;
}

.demo-tip:popover-open {
  opacity: 1;
  translate: 0 0;

  @starting-style {
    opacity: 0;
    translate: 0 4px;
  }
}

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

.demo-toolbar {
  display: inline-flex;
  gap: 4px;
  padding: 4px;
  border: 1px solid var(--color-border);
  border-radius: 6px;
}

.demo-icon-button {
  display: inline-grid;
  place-items: center;
  inline-size: 2rem;
  block-size: 2rem;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  background: var(--color-bg);
  color: var(--color-text);
  cursor: pointer;
}

/* The trigger whose tooltip is open: the popup is the next sibling. */
.demo-icon-button:has(+ .demo-tip:popover-open),
.demo-button:has(+ .demo-tip:popover-open) {
  border-color: var(--color-primary);
}

Hover a button, or move focus to it with the keyboard. Escape, or any click, closes the tooltip. This example is a Server Component.

Anatomy

import { Tooltip } from "@funstack/ui/tooltip";

<Tooltip.Root>                              {/* no DOM: id, options, interaction */}
  <Tooltip.Trigger>Save</Tooltip.Trigger>   {/* button */}
  <Tooltip.Popup>Saves the document (Ctrl+S)</Tooltip.Popup>  {/* div[popover][role=tooltip] */}
</Tooltip.Root>
<button type="button" aria-describedby="_r_1_">Save</button>
<div id="_r_1_" popover="manual" role="tooltip" data-side="top" data-align="center"
     style="position-area: top; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline">
  Saves the document (Ctrl+S)
</div>
  • Root renders no DOM, so the trigger stays a direct child of your toolbar or flex row. There is no Portal and no Positioner: the top layer and anchor positioning do those jobs.
  • Render the Popup immediately after the Trigger. The HTML spec recommends that reading order for popovers, and some screen readers attach an adjacent tooltip to the wrong control otherwise.
  • One trigger per Root.
  • There is no Arrow part in this version. Draw one on the popup with CSS if you need it (it cannot follow a fallback flip).

Props

Tooltip.Root

PropTypeDefaultDescription
delaynumber250Milliseconds from hover or keyboard focus to open (Chromium's interest-delay-start). Hover and focus share it.
closeDelaynumber150Milliseconds from leaving (trigger and popup) or blur to close (Chromium's interest-delay-end).
disabledbooleanfalseThe tooltip never opens. The ARIA relation stays in place.
provides"description" | "name""description"Which relation the tooltip provides for the trigger. "description": aria-describedby, for triggers with their own name. "name": aria-labelledby, for icon-only triggers; do not also set aria-label.
childrenReactNode | ((state: TooltipRootState) => ReactNode)–The Trigger and the Popup.

TooltipRootState: { disabled }. No ref, className or style (no DOM), and no open, defaultOpen or action (see below).

Tooltip.Trigger

PropTypeDefaultDescription
experimental_interestForbooleanfalseRenders interestfor and interest-delay-start/end so the browser owns hover, focus, Escape and long-press. Where the element implements interestForElement (Chromium), the library attaches no listeners; elsewhere the attribute is ignored and the JS path runs. Pair with Popup experimental_hint.
classNamestring | ((state: TooltipTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TooltipTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TooltipTriggerState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: TooltipTriggerState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

TooltipTriggerState: { disabled }. Every <button> prop passes through, including onClick and type (default "button"): the library attaches its listeners with addEventListener. Your own aria-describedby / aria-labelledby ids are merged with the tooltip's.

Tooltip.Popup

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right" | null"top"Placement through inline position-area, with position-try-fallbacks flipping it when it does not fit. null emits no inline positioning and no data-side / data-align, so your stylesheet owns placement.
align"start" | "center" | "end""center"Alignment along the side. Ignored when side is null.
experimental_hintbooleanfalseRenders popover="hint" instead of "manual": native light dismiss, and the tooltip can open over an open menu without closing it. Engines that do not know hint treat it as manual, and the JS Escape and click handling stays.
classNamestring | ((state: TooltipPopupState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TooltipPopupState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TooltipPopupState) => ReactNode)–Rendered at mount (hidden), not at open time. Keep it static and non-interactive.
renderReactElement | ((props, state: TooltipPopupState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

TooltipPopupState: { disabled, side, align }. id, popover and role are owned by the library. Your style wins over the inline placement, so style={{ positionArea: "bottom span-right" }} works for a one-off.

Open state belongs to the browser

There is no open prop, no openChangeAction and no data-open. The popup is a popover in the top layer, opened and closed by the browser (or by the library's listeners calling showPopover / hidePopover), never through React state. Style it with :popover-open, and the trigger of an open tooltip with :has(+ :popover-open). Tooltip is therefore the one component with no transitions, no data-pending and no transition prop; animate it in CSS:

.tip {
  opacity: 0;
  translate: 0 4px;
  transition: opacity 120ms, translate 120ms, display 120ms allow-discrete, overlay 120ms allow-discrete;
}
.tip:popover-open {
  opacity: 1;
  translate: 0 0;
  @starting-style { opacity: 0; translate: 0 4px; }
}
@media (prefers-reduced-motion: reduce) {
  .tip { transition: none; }
}

To observe opens (for analytics), listen to the popup's native toggle event through a ref.

Data attributes

HookOnMeaning
:popover-openPopupThe tooltip is showing.
data-sidePopupThe requested side (absent with side={null}).
data-alignPopupThe requested alignment (absent with side={null}).
data-disabledTrigger, PopupThe Root's disabled prop.

data-side is the side you asked for, not the side the browser picked after a fallback flip; CSS exposes no signal for that yet.

Keyboard, pointer and ARIA

InteractionBehaviour
Hover the triggerOpens after delay. Stays open while the pointer is over the trigger or the popup.
Pointer leaves bothCloses after closeDelay.
Keyboard focus on the triggerOpens after delay (focus from a click does not open).
BlurCloses after closeDelay.
EscapeCloses; focus stays on the trigger.
Any pointerdownCloses (outside, on the trigger, on the popup). It reopens only after the pointer leaves and re-enters, or focus leaves and returns.
TouchNever opens on the JS path (no hover). Chromium's native path offers long-press.
  • The popup has role="tooltip"; the trigger gets aria-describedby or aria-labelledby per provides, and no aria-expanded. The relation works while the popup is hidden, so screen readers announce the text on focus in every engine.
  • The trigger must be focusable. A natively disabled button never shows a tooltip; use aria-disabled="true" on a control that needs one.
  • Tooltips must not contain interactive content. Use a dialog or popover for that.
  • Do not put the only copy of important information in a tooltip: touch users may never see it.

In development, the library warns when provides="name" is combined with an aria-label, when the trigger is not focusable, and when the popup contains focusable content.

Two mechanisms

FlagsChromiumFirefoxSafari
nonemanual popover + JSsamesame
experimental_hinthint popover: native light dismiss, JS still attachedsame as Chromiumhint unknown: behaves as manual + JS
experimental_interestFornative trigger, no JS listenersattribute ignored, JS pathattribute ignored, JS path
bothfully native, works before hydrationas experimental_hintas no flags
<Tooltip.Root provides="name">
  <Tooltip.Trigger experimental_interestFor className="icon-button">
    <SaveIcon />
  </Tooltip.Trigger>
  <Tooltip.Popup experimental_hint className="tip">Save</Tooltip.Popup>
</Tooltip.Root>

The baseline popover type is manual, not auto, so that opening a tooltip never closes an open menu or listbox.

Recipes

A link trigger

<Tooltip.Root>
  <Tooltip.Trigger render={<a href="/pricing" />}>Pricing</Tooltip.Trigger>
  <Tooltip.Popup className="tip">Opens the pricing page</Tooltip.Popup>
</Tooltip.Root>

The native interestfor path only applies to buttons and links; any other element (a focusable <span tabIndex={0}>) always uses the JS path.

Placement entirely in CSS

<Tooltip.Popup side={null} className="tip tip-inline-end">...</Tooltip.Popup>
.tip-inline-end {
  position-area: inline-end;
  position-try-fallbacks: flip-inline;
  margin-inline: 6px;
}

Async content

The popup's content renders when the tooltip mounts, not when it opens. If it must suspend, put the <Suspense> boundary inside Tooltip.Popup; a boundary above the Root would hide the trigger too.