Tooltip
Hover- and focus-triggered tooltip that links to its trigger via aria-describedby, with placement, arrow, delay and side-offset props plus a rich-content slot.
Preview
<div class="flex items-center gap-6">
<x-ui.tooltip :text="__('Add to library')">
<x-ui.button variant="outline">{{ __('Hover or focus me') }}</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip :text="__('This appears below')" side="bottom">
<x-ui.button variant="ghost">{{ __('Bottom') }}</x-ui.button>
</x-ui.tooltip>
</div>
Installation
php artisan ui:add tooltip
Note
This component ships an Alpine behavior module at
resources/js/ui/tooltip.js. Import it once from your bundle so it registers on alpine:init:
import './tooltip.js';
Registry contract
php artisan ui:add tooltip
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/tooltip.blade.php -
resources/js/ui/tooltip.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Tooltip (`tooltip`)
Hover- and focus-triggered tooltip that links to its trigger via aria-describedby, with placement, arrow, delay and side-offset props plus a rich-content slot.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add tooltip
```
## Usage
```blade
<div class="flex items-center gap-6">
<x-ui.tooltip :text="__('Add to library')">
<x-ui.button variant="outline">{{ __('Hover or focus me') }}</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip :text="__('This appears below')" side="bottom">
<x-ui.button variant="ghost">{{ __('Bottom') }}</x-ui.button>
</x-ui.tooltip>
</div>
```
## Props
- `text` (string, default ``) — Plain tooltip text; ignored when the content slot is used.
- `side` (string, default `top`) — Placement relative to the trigger: top, bottom, start, or end (left/right are accepted aliases and stay RTL-aware).
- `arrow` (bool, default `false`) — Renders a token-coloured caret pointing at the trigger.
- `delay` (int, default `150`) — Hover open delay in milliseconds; keyboard focus always opens immediately.
- `sideOffset` (int, default `6`) — Gap in pixels between the trigger and the tooltip content.
- `content` (string, default `''`) — Tooltip text.
## Use when
- Use for brief nonessential clarification of an existing control.
- Adding a short, supplementary label to a control, such as clarifying an icon-only button.
- Needing the description to be announced to screen readers via aria-describedby, without stealing focus.
## Avoid when
- Do not put critical instructions, errors, or primary actions in a tooltip.
- Keep content terse and supplementary. Critical guidance must remain visible without hover or focus.
- The content is essential to complete a task; do not hide required information behind hover-only content.
- The content is rich or interactive (links, buttons); use hover-card or popover instead.
## Anti-patterns
- Critical instructions or errors in tooltips
- Hover-only essential content
## Rules
- Use the `<brok:tooltip>` tag (or `<x-ui.tooltip>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/tooltip
- Registry JSON (files, props, contract): https://brokui.dev/r/open/tooltip.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="flex items-center justify-center gap-6 py-8">
<x-ui.tooltip side="bottom" :arrow="true">
<x-ui.button variant="outline">{{ __('Rich content') }}</x-ui.button>
<x-slot:content>
<span class="block max-w-56 space-y-1 text-start">
<span class="block text-xs font-semibold text-background">{{ __('Keyboard shortcuts') }}</span>
<span class="block text-xs font-normal leading-relaxed text-background/80">
{{ __('Speed up your workflow with quick commands that work anywhere in the app.') }}
</span>
</span>
</x-slot:content>
</x-ui.tooltip>
</div>
<div class="flex items-center justify-center gap-6 py-8">
<x-ui.tooltip side="top" :arrow="true">
<x-ui.button variant="outline">{{ __('Save') }}</x-ui.button>
<x-slot:content>
<span class="inline-flex items-center gap-2">
<span>{{ __('Save changes') }}</span>
<x-ui.kbd>{{ __('⌘') }}</x-ui.kbd>
<x-ui.kbd>{{ __('S') }}</x-ui.kbd>
</span>
</x-slot:content>
</x-ui.tooltip>
<x-ui.tooltip :text="__('Opens instantly')" side="bottom" :delay="0" :arrow="true">
<x-ui.button variant="ghost">{{ __('Instant') }}</x-ui.button>
</x-ui.tooltip>
</div>
<div class="flex flex-wrap items-center justify-center gap-6 py-8">
<x-ui.tooltip :text="__('On top')" side="top" :arrow="true">
<x-ui.button variant="outline">{{ __('Top') }}</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip :text="__('On the end side')" side="end" :arrow="true">
<x-ui.button variant="outline">{{ __('Right') }}</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip :text="__('On the bottom')" side="bottom" :arrow="true">
<x-ui.button variant="outline">{{ __('Bottom') }}</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip :text="__('On the start side')" side="start" :arrow="true">
<x-ui.button variant="outline">{{ __('Left') }}</x-ui.button>
</x-ui.tooltip>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | Plain tooltip text; ignored when the content slot is used. | |
| side | string | top | Placement relative to the trigger: top, bottom, start, or end (left/right are accepted aliases and stay RTL-aware). |
| arrow | bool | false | Renders a token-coloured caret pointing at the trigger. |
| delay | int | 150 | Hover open delay in milliseconds; keyboard focus always opens immediately. |
| sideOffset | int | 6 | Gap in pixels between the trigger and the tooltip content. |
| content | string | '' | Tooltip text. |
Slots
default— Trigger content the tooltip describes.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Shows on hover (after the delay) and on keyboard focus (immediately), and hides on blur, mouse leave, or Escape.
- The trigger is linked to the tooltip via aria-describedby, so screen readers announce the tooltip text alongside the trigger.
- Rich content, such as a title plus description or a keyboard shortcut, is composed in the content slot instead of the text prop.
- Shows contextual help on hover/focus and hides on blur or pointer leave.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Clarify an already understandable control.
Use when
- Use for brief nonessential clarification of an existing control.
- Adding a short, supplementary label to a control, such as clarifying an icon-only button.
- Needing the description to be announced to screen readers via aria-describedby, without stealing focus.
Avoid when
- Do not put critical instructions, errors, or primary actions in a tooltip.
- Keep content terse and supplementary. Critical guidance must remain visible without hover or focus.
- The content is essential to complete a task; do not hide required information behind hover-only content.
- The content is rich or interactive (links, buttons); use hover-card or popover instead.
Use instead
- Visible label or help text
Anti-patterns
- Critical instructions or errors in tooltips
- Hover-only essential content
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Escape
- Focus
managed
- The tooltip uses role="tooltip" and aria-describedby so its content is announced without moving focus.
- Because keyboard focus opens the tooltip immediately and Escape closes it, keyboard-only users get the same information as pointer users.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="tooltip-{{ $record->id }}">
<div class="flex items-center gap-6">
<x-ui.tooltip :text="__('Add to library')">
<x-ui.button variant="outline">{{ __('Hover or focus me') }}</x-ui.button>
</x-ui.tooltip>
<x-ui.tooltip :text="__('This appears below')" side="bottom">
<x-ui.button variant="ghost">{{ __('Bottom') }}</x-ui.button>
</x-ui.tooltip>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'text' => '',
'side' => 'top',
'arrow' => false,
'delay' => 150,
'sideOffset' => 6,
])
{{--
Tooltip (spec §17 + §19). Shows on hover and keyboard focus, hides on blur,
mouse leave or Escape. The trigger is described by the tooltip via
aria-describedby so screen readers announce it.
Props:
side top | bottom | start | end (left/right are accepted aliases for
start/end and stay RTL-aware via logical inset properties).
arrow render a token-coloured caret pointing at the trigger.
delay hover open delay in ms (focus is always immediate).
sideOffset gap in px between trigger and content (CSS var, JIT-safe).
Rich content (a title + description) and keyboard shortcuts are composed in
the content slot — pass markup as the second slot instead of `text`.
--}}
@php
// Accept left/right as aliases so callers reading the upstream API still work.
$resolvedSide = match ($side) {
'left' => 'start',
'right' => 'end',
default => $side,
};
@endphp
<span
x-data="uiTooltip({ delay: {{ (int) $delay }} })"
x-id="['tooltip']"
data-slot="tooltip"
data-side="{{ $resolvedSide }}"
style="--tooltip-offset: {{ (int) $sideOffset }}px"
{{ $attributes->merge(['class' => 'relative inline-flex']) }}
@mouseenter="show()"
@mouseleave="hide()"
@focusin="show(true)"
@focusout="hide()"
@keydown.escape="hide()"
>
<span data-slot="tooltip-trigger" class="inline-flex">
{{ $slot }}
</span>
<span
data-slot="tooltip-content"
role="tooltip"
x-bind:id="$id('tooltip')"
x-show="open"
x-cloak
x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
x-transition:enter-start="opacity-0 motion-safe:scale-[0.97]"
x-transition:enter-end="opacity-100 motion-safe:scale-100"
x-transition:leave="motion-safe:transition motion-safe:duration-75 motion-safe:ease-in"
x-transition:leave-start="opacity-100 motion-safe:scale-100"
x-transition:leave-end="opacity-0 motion-safe:scale-[0.97]"
@class([
'pointer-events-none absolute z-tooltip w-max max-w-xs rounded-md bg-foreground px-2 py-2 text-xs font-medium text-background shadow-md motion-reduce:transition-none',
'origin-bottom' => $resolvedSide === 'top',
'origin-top' => $resolvedSide === 'bottom',
'origin-right rtl:origin-left' => $resolvedSide === 'start',
'origin-left rtl:origin-right' => $resolvedSide === 'end',
'bottom-full start-1/2 -translate-x-1/2 rtl:translate-x-1/2 mb-[var(--tooltip-offset)]' => $resolvedSide === 'top',
'top-full start-1/2 -translate-x-1/2 rtl:translate-x-1/2 mt-[var(--tooltip-offset)]' => $resolvedSide === 'bottom',
'end-full top-1/2 -translate-y-1/2 me-[var(--tooltip-offset)]' => $resolvedSide === 'start',
'start-full top-1/2 -translate-y-1/2 ms-[var(--tooltip-offset)]' => $resolvedSide === 'end',
])
>
{{ $content ?? $text }}
@if ($arrow)
<span
data-slot="tooltip-arrow"
aria-hidden="true"
@class([
'absolute size-2 rotate-45 bg-foreground',
'top-full start-1/2 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2' => $resolvedSide === 'top',
'bottom-full start-1/2 -translate-x-1/2 translate-y-1/2 rtl:translate-x-1/2' => $resolvedSide === 'bottom',
'start-full top-1/2 -translate-x-1/2 -translate-y-1/2' => $resolvedSide === 'start',
'end-full top-1/2 translate-x-1/2 -translate-y-1/2' => $resolvedSide === 'end',
])
></span>
@endif
</span>
</span>
/**
* Tooltip behavior (spec §19). A short open delay on hover avoids flicker while
* moving the pointer across the page; keyboard focus shows it immediately.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTooltip', (config = {}) => ({
open: false,
delay: config.delay ?? 150,
timer: null,
trigger: null,
descriptionId: null,
init() {
this.$nextTick(() => {
const wrapper = this.$root.querySelector('[data-slot="tooltip-trigger"]');
const tooltip = this.$root.querySelector('[role="tooltip"]');
this.trigger = wrapper?.querySelector(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
this.descriptionId = tooltip?.id || null;
if (!this.trigger || !this.descriptionId) {
return;
}
const ids = new Set((this.trigger.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean));
ids.add(this.descriptionId);
this.trigger.setAttribute('aria-describedby', Array.from(ids).join(' '));
});
},
show(immediate = false) {
clearTimeout(this.timer);
if (immediate) {
this.open = true;
return;
}
this.timer = setTimeout(() => {
this.open = true;
}, this.delay);
},
hide() {
clearTimeout(this.timer);
this.open = false;
},
destroy() {
clearTimeout(this.timer);
if (!this.trigger || !this.descriptionId) {
return;
}
const ids = (this.trigger.getAttribute('aria-describedby') || '')
.split(/\s+/)
.filter((id) => id && id !== this.descriptionId);
if (ids.length > 0) {
this.trigger.setAttribute('aria-describedby', ids.join(' '));
} else {
this.trigger.removeAttribute('aria-describedby');
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.6 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0