Skip to content

Tooltip

Hover/focus restatement. Never the only home of a fact.
Implemented
Status
Implemented
Family
1 of 13
Registry
@lopifi/tooltip
npx shadcn@latest add @lopifi/theme @lopifi/tooltip

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

A restatement of something already on screen. Never the only home of a fact.

<Tooltip content="30 Sep 2026, 09:12 UTC">
  <time>2m ago</time>
</Tooltip>

<TooltipProvider delay={400}>{/* a toolbar, a table, an app */}</TooltipProvider>
interface TooltipProps {
  /** Nullable on purpose: a truncated cell has one, a short cell does not. */
  content: React.ReactNode;
  children: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
  /** Overrides the provider's delay for this one trigger. */
  delay?: number;
  disabled?: boolean;
  /** `block` for a child that fills its column — a truncating cell. */
  display?: "inline" | "block";
  triggerClassName?: string;
}

It renders its own trigger span and puts children inside it, rather than cloning the caller's element. That is not a stylistic choice: a Server Component passes its JSX children across the boundary as a lazy node, not an element — isValidElement is false and type is undefined — so a render={children} clone renders undefined and takes the page down with it. Rendering the child as a child works from either side of the boundary. The cost is one <span>, and display="block" is how a truncating child keeps its width.

What may go in one. The unabbreviated figure behind $1.2M. The absolute timestamp behind "2m ago". The untruncated merchant name behind an ellipsis. The rate and instant behind a ≈ conversion. The comparison basis behind a delta on a card too narrow to carry it inline. In every case the tooltip restates — it never introduces.

What may not. Anything a user must read to act. Tooltips do not exist on touch, so a sentence that only lives in one is a sentence a phone user never sees. If it is essential, inline it; if it is additional, it belongs in an InfoTip, which opens on press.

Layer 3 — structure: radius 8, --surface-raised, hairline + --elevation-2, 12px caption, max-width 264px, 8px from its anchor.

Motion: enter opacity 0→1, scale 0.97→1 over 140ms --ease-out; exit opacity over 100ms; 400ms delay before the first one. Once one is open, hovering a neighbour opens it instantly — Base UI sets data-instant, and the transition duration must go to 0ms with it or the "instant" tooltip still visibly fades. Wrap a dense surface in TooltipProvider to get that grouping.

Accessibility: the trigger keeps its own accessible name; the tooltip is aria-describedby. It opens on keyboard focus, closes on Escape, and is never the target of a click.

Lifted verbatim from Primitives. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/tooltip.tsx

Lopifi dependencies · 1
cn
npm packages
@base-ui-components/reactclsxtailwind-merge
Registry entry
/r/tooltip.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/tooltip.tsx
"use client";

import { Tooltip as BaseTooltip } from "@base-ui-components/react/tooltip";
import type * as React from "react";
import { cn } from "@/lib/ledger/cn";

/**
 * The system's one floating-context surface.
 *
 * Two components, and the distinction is not cosmetic — it is the whole point:
 *
 * - `Tooltip` labels something already on screen. Hover and focus only. It is
 *   unreachable on touch by design, so it may only ever carry a *restatement*
 *   — the unabbreviated figure, the full timestamp, the untruncated name.
 * - `InfoTip` carries context that is genuinely additional. It is a real
 *   button, so it opens on press as well as on hover, which makes it the only
 *   one of the two a touch user can open.
 *
 * The rule that follows: if a sentence would be lost on a phone, it belongs in
 * an `InfoTip` (or inline), never in a `Tooltip`.
 */

export const TOOLTIP_DELAY = 400;

/** e2, radius 8, hairline + shadow. Shared so the two never drift apart. */
export const TOOLTIP_SURFACE = cn(
  "z-tooltip max-w-[264px] rounded-sm border border-border bg-surface-raised",
  "px-3 py-2 text-caption text-text-primary shadow-elevation-2",
);

/**
 * Enter 140ms, exit 100ms, opacity + scale only. `data-instant` is set by Base
 * UI when a tooltip opens without its delay — hovering along a toolbar after
 * the first one is already open — and it must skip the animation too, or the
 * "instant" second tooltip still visibly fades.
 */
export const TOOLTIP_MOTION = cn(
  "origin-[var(--transform-origin)] transition-[opacity,transform] duration-2 ease-out",
  "data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0",
  "data-[ending-style]:scale-[0.97] data-[ending-style]:opacity-0 data-[ending-style]:duration-1",
  "data-[instant]:duration-0",
);

export interface TooltipProviderProps {
  children?: React.ReactNode;
  closeDelay?: number;
  /** Before the first tooltip in a group. Subsequent ones open instantly. */
  delay?: number;
}

/**
 * Wrap a surface — a toolbar, a table, an app — so its tooltips share one
 * delay. Without it every tooltip waits 400ms independently, which makes a
 * dense toolbar feel unresponsive; with it, only the first one waits.
 */
export function TooltipProvider({ children, delay = TOOLTIP_DELAY, closeDelay = 0 }: TooltipProviderProps) {
  return (
    <BaseTooltip.Provider closeDelay={closeDelay} delay={delay}>
      {children}
    </BaseTooltip.Provider>
  );
}

export type TooltipSide = "top" | "bottom" | "left" | "right";
export type TooltipAlign = "start" | "center" | "end";

export interface TooltipProps {
  align?: TooltipAlign;
  children: React.ReactNode;
  /** Classes for the popup. */
  className?: string;
  /** Supplementary only. Never the sole home of anything a user must read. */
  content: React.ReactNode;
  /** Overrides the provider's delay for this one trigger. */
  delay?: number;
  disabled?: boolean;
  /**
   * The trigger's own display. `inline` wraps a phrase inside a line;
   * `block` is for a child that fills its column — a truncating cell, a table
   * memo — where an inline trigger would shrink-wrap it and defeat the
   * ellipsis it exists to reveal.
   */
  display?: "inline" | "block";
  side?: TooltipSide;
  /** Classes for the trigger element, not the popup. */
  triggerClassName?: string;
}

/**
 * `content` is nullable on purpose: a truncated cell has a tooltip and a short
 * one does not, and the call site should not have to branch.
 */
export function Tooltip({
  content,
  children,
  side = "top",
  align = "center",
  delay,
  disabled,
  display = "inline",
  triggerClassName,
  className,
}: TooltipProps) {
  if (disabled || content == null || content === "") return <>{children}</>;

  return (
    <BaseTooltip.Root>
      {/* The trigger is our own element with `children` inside it, rather than
          Base UI cloning the caller's element via `render={children}`.

          That looks like the tidier API and it is a trap: a Server Component
          hands its JSX children across the boundary as a *lazy* node, not an
          element — `isValidElement` is false and `type` is undefined — so the
          clone renders `undefined` and the page throws. Rendering the child as
          a child sidesteps it entirely, and works from either side. */}
      <BaseTooltip.Trigger
        className={cn(display === "block" ? "block min-w-0" : "inline", triggerClassName)}
        delay={delay}
        render={<span />}
      >
        {children}
      </BaseTooltip.Trigger>
      <BaseTooltip.Portal>
        <BaseTooltip.Positioner align={align} className="z-tooltip" side={side} sideOffset={8}>
          <BaseTooltip.Popup className={cn(TOOLTIP_SURFACE, TOOLTIP_MOTION, className)}>
            {content}
          </BaseTooltip.Popup>
        </BaseTooltip.Positioner>
      </BaseTooltip.Portal>
    </BaseTooltip.Root>
  );
}

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in Primitives

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where Tooltip sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when Primitives changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.