---
title: Tooltip
description: One line on hover and focus, in the markup rather than in title= - and never carrying controls.
source: https://adamjurek.com/components/tooltip
---

## Home


A single line of text revealed on hover and on focus, written in the markup
instead of a `title` attribute, so every reader - mouse or keyboard - sees the
same thing. Use it for a short label on an icon button or an abbreviation,
never for anything that needs a link or a control inside it.

<!-- ::start:showcase demo="tooltip" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

A tooltip is a `title` attribute with better clothes. CSS reveals the bubble
on hover and on `:focus-within`, so keyboard users get it too, and because the
label lives in the markup instead of `title=`, every reader sees the same
consistent thing rather than the browser's own rendering of it. It never
carries controls on purpose - anything that needs to be clicked or read at
length is the reference hover card, not this.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/tooltip.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/tooltip.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Feedback |
| Files | `tooltip.tsx` |
| Dependencies | None |
| Tags | hover, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Icon, Tooltip } from "@sushindustries/ui";

export function CopyIconButton() {
	return (
		<Tooltip label="Copy to clipboard">
			<button type="button" aria-label="Copy to clipboard">
				<Icon name="copy" size={16} />
			</button>
		</Tooltip>
	);
}
```

## What you should see

Nothing, at first. The child renders exactly as it would on its own - `Tooltip`
wraps it in an inline-block span and adds nothing visible. Hover the button, or
tab to it, and a small dark bubble appears above it after a short pause,
carrying the label. Move away or blur it and the bubble fades out immediately.

## If nothing happens

There is no touch fallback. `:hover` and `:focus-within` are the only two
triggers the CSS listens for, so a tap on a touchscreen with no keyboard
focus behind it never reveals the bubble. If the label needs to reach a touch
reader, it needs another route to the same information, not this component.


## Guides


## Composing it

`Tooltip` wraps its child in an inline-block span, so it sits inline wherever
the child would: around a word, around an icon-only button, around anything
with a box to hover. There is no layout requirement beyond that - the bubble
is `position: absolute` against the wrapper, so it never affects the height of
whatever contains it.

## The delay is one-sided

The bubble waits 350ms before it appears and fades out in 140ms with no delay
at all. That asymmetry is deliberate: a tooltip that opened the instant the
pointer landed would flash on every pass the cursor makes on its way
somewhere else, but once it is open there is no reason to make somebody wait
to see it go.

## When not to use it

It is one line by contract. Anything richer - a preview, a set of facts, a
link - is the reference hover card, not this. And it never carries controls:
the bubble is `role="tooltip"` and not reachable by tab, so a link or button
placed inside it is invisible to a keyboard.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `label` | `string` | - | The one line. A tooltip that needs two is a hover card. |
| `children` | `ReactNode` | - |  |

<!-- /generated:api -->

## Notes

`children` takes whatever needs the label, wrapped in an inline-block span -
text, an icon, a whole button. There is no `disabled` or `open` prop: the
bubble's visibility is pure CSS, driven by `:hover` and `:focus-within` on the
wrapper, so there is no state to get out of sync with the pointer.

The bubble is not wired to `children` with `aria-describedby`. A screen
reader that announces it does so by convention on `role="tooltip"`, not
because this component built the association - if the label is information a
screen reader user must not miss, give the child its own `aria-label` too
rather than relying on the bubble alone.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="tooltip" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

An icon-only button is the case this exists for - the icon alone tells a
sighted mouse user nothing about what it does:

```tsx
import { Icon, Tooltip } from "@sushindustries/ui";

export function CardActions() {
	return (
		<div className="flex gap-2">
			<Tooltip label="Copy to clipboard">
				<button type="button" aria-label="Copy to clipboard">
					<Icon name="copy" size={16} />
				</button>
			</Tooltip>
			<Tooltip label="Open in a new tab">
				<button type="button" aria-label="Open in a new tab">
					<Icon name="expand" size={16} />
				</button>
			</Tooltip>
		</div>
	);
}
```

## What this example is not

The `aria-label` on each button is doing the real accessibility work here,
not the tooltip. The bubble is a sighted-hover convenience; a screen reader
user gets the button's name from `aria-label` regardless of whether they ever
trigger the tooltip at all.
