Skip to content
LarawellUi

<x-widget.tooltip>

Tooltip

A short label or hint on an element, shown when the pointer rests on it or it gets keyboard focus, and hidden on Esc. On any side, moving to the other when there's no room, and never clipped by a table or a modal. Read out by screen readers as the element's description. Works inside Livewire components.

php artisan larawell:add tooltip
Also adds
Icon

Usage

Livewire

Inside a Livewire component a tooltip's text updates with each render, and it stays linked to its element for screen readers. Wrap the element, not the component's root.

Blade
<x-widget.tooltip :text="$synced ? 'Synced '.$syncedAt->diffForHumans() : 'Not synced yet'">
    <x-widget.button variant="neutral" icon="refresh-cw" label="Sync now" wire:click="sync" />
</x-widget.tooltip>

Examples

Icon buttons

The usual place for a tooltip: buttons that are only an icon, which have no visible text. It shows after a short rest of the pointer, at once on keyboard focus, and hides on Esc. A title saying the same thing is dropped, so the browser's own tooltip doesn't show as well. Works the same around <x-widget.button icon="…">.

Show code
Blade
<div class="flex gap-2">
    @foreach (['pencil' => 'Edit', 'copy' => 'Copy link', 'trash' => 'Delete'] as $icon => $label)
        <x-widget.tooltip :text="$label">
            <button type="button" aria-label="{{ $label }}" class="bg-field hover:bg-line focus-visible:ring-primary grid size-11 place-items-center rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-offset-2">
                <x-widget.icon :name="$icon" class="size-4" />
            </button>
        </x-widget.tooltip>
    @endforeach
</div>

Placement

placement is top (the default), bottom, start or end; start and end follow the page's direction. Without room on that side it moves to the opposite one.

Show code
Blade
<div class="flex flex-wrap gap-3">
    @foreach (['top', 'bottom', 'start', 'end'] as $side)
        <x-widget.tooltip :text="'On the '.$side" :placement="$side">
            <button type="button" class="bg-field hover:bg-line focus-visible:ring-primary rounded-xl px-4 py-2.5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2">{{ ucfirst($side) }}</button>
        </x-widget.tooltip>
    @endforeach
</div>

On text

Around something that can't take focus (an icon, a word), the tooltip makes it focusable, so keyboard users reach it too, and an icon with no text of its own is named by the tooltip. Keep it to text that adds to what's there; don't hide what someone needs in it.

Last synced 2 minutes ago

Show code
Blade
<p class="text-sm">
    Last synced 2 minutes ago
    <x-widget.tooltip text="Syncs every 5 minutes while the app is open" placement="end" class="align-middle">
        <x-widget.icon name="info" class="text-foreground/60 size-4" />
    </x-widget.tooltip>
</p>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.tooltip>

Prop Default Description
text Required What it says: a few words that name or explain what it's on ("Copy link", "Last synced 2 minutes ago").
placement 'top' Which side of it the tooltip goes: top (the default), bottom, start or end. It moves to the opposite side when there's no room.
id null Defaults to one made for it; the element inside points at it with aria-describedby.

Source

What larawell:add tooltip writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/tooltip/index.blade.php Show
index.blade.php
@props([
    // What it says: a few words that name or explain what it's on ("Copy link", "Last synced 2 minutes ago").
    'text',
    // Which side of it the tooltip goes: top (the default), bottom, start or end. It moves to the opposite side when
    // there's no room.
    'placement' => 'top',
    // Defaults to one made for it; the element inside points at it with aria-describedby.
    'id' => null,
])

@php
    $placement = in_array($placement, ['top', 'bottom', 'start', 'end'], true) ? $placement : 'top';
    $id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'tooltip', explicit: $id !== null);
@endphp

{{--
    Wraps one element: a button, a link, an icon. The tooltip shows when the pointer rests on it or it gets keyboard
    focus, stays while the pointer moves onto the tooltip, and hides on Esc (WCAG 1.4.13). It's text only: anything to
    click goes in a dropdown or a modal. It sits in the top layer (a popover), so a table or a modal can't clip it.
    resources/js/widget/tooltip points the element's aria-describedby at it, so screen readers read it too.
--}}
<span data-tooltip="{{ $id }}" data-placement="{{ $placement }}" {{ $attributes->class(['inline-flex']) }}>{{ $slot }}<span
        id="{{ $id }}"
        popover="manual"
        role="tooltip"
        {{-- Never a Tab stop: a showing popover would otherwise take focus between its element and the next one. --}}
        tabindex="-1"
        {{-- The script positions it (inline top and left); a Livewire render would wipe that. The text still updates. --}}
        wire:ignore.self
        data-tooltip-bubble
        class="group/tip bg-foreground text-surface pointer-events-auto fixed inset-auto m-0 hidden max-w-64 overflow-visible rounded-lg px-2.5 py-1.5 text-xs leading-snug font-medium shadow-lg open:block opacity-0 transition-opacity duration-150 open:opacity-100 starting:open:opacity-0 motion-reduce:transition-none"
    >{{ $text }}{{-- The tail, on the side facing the element (data-side, set by the script, which also lines it up with the
         element's middle when the tooltip is pushed in from the window's edge). --}}<span
            data-tooltip-arrow
            aria-hidden="true"
            class="bg-foreground absolute size-2 rotate-45 rounded-[1px] group-data-[side=top]/tip:-bottom-1 group-data-[side=bottom]/tip:-top-1 group-data-[side=left]/tip:-right-1 group-data-[side=right]/tip:-left-1"
        ></span></span></span><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/js/widget/tooltip/index.js Show
index.js
// Drives <x-widget.tooltip>. Shows it when the pointer rests on the element or it gets keyboard focus, keeps it while
// the pointer moves onto the tooltip, and hides it on Esc, a press, or leaving (WCAG 1.4.13). Points the element's
// aria-describedby at it, so screen readers read it as the element's description. Delegated from `document`, so
// tooltips added later (a Livewire render, fetched HTML) work without setting up.

const ROOT = '[data-tooltip]';
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
const SHOW_AFTER_MS = 300;
const HIDE_AFTER_MS = 120;
const GAP = 8;
const VIEWPORT_EDGE = 8;
// How far the tail keeps from the tooltip's corners.
const ARROW_INSET = 8;

let open = null;
let showTimer = null;
let hideTimer = null;

const bubbleOf = (root) => root.querySelector(':scope > [data-tooltip-bubble]');

// The element the tooltip describes: the first thing in it that can take focus, or the wrapper itself, made
// focusable, so keyboard users can reach a tooltip on plain text or an icon too.
function targetOf(root) {
    return [...root.children].find((child) => !child.matches('[data-tooltip-bubble]') && child.matches(FOCUSABLE))
        ?? root.querySelector(`:scope > :not([data-tooltip-bubble]) ${FOCUSABLE}`)
        ?? root;
}

// Points the target's aria-describedby at the tooltip (keeping the target's own ids), and drops a title saying the
// same thing, which would otherwise show the browser's own tooltip on top of this one. Run again after a Livewire
// render, which puts back the server's markup without them.
function link(root) {
    const bubble = bubbleOf(root);
    const target = targetOf(root);
    if (!bubble || !target) {
        return;
    }
    if (target === root && !root.hasAttribute('tabindex')) {
        root.tabIndex = 0;
    }
    // Made focusable around an icon with no text of its own, it would have no name at all: the tooltip's text is its
    // name then, not a description of one.
    if (target === root && [...root.childNodes].every((node) => node === bubble || !node.textContent.trim())) {
        root.setAttribute('aria-label', bubble.textContent.trim());

        return;
    }
    const ids = new Set((target.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean));
    if (!ids.has(bubble.id)) {
        target.setAttribute('aria-describedby', [...ids, bubble.id].join(' '));
    }
    if (target.getAttribute('title')?.trim() === bubble.textContent.trim()) {
        target.removeAttribute('title');
    }
}

function linkAll(scope = document) {
    if (scope instanceof Element && scope.matches(ROOT)) {
        link(scope);
    }
    scope.querySelectorAll?.(ROOT).forEach(link);
}

// Whether any of the element can still be seen: not scrolled out of a container that clips it (a table that scrolls
// inside itself, a modal's body) or the window, and not covered there (a table's sticky header). The same check as
// '../field''s inView, kept here since the tooltip needs nothing else from the field.
function inView(element, bubble) {
    const box = element.getBoundingClientRect();
    let [top, right, bottom, left] = [Math.max(box.top, 0), Math.min(box.right, window.innerWidth), Math.min(box.bottom, window.innerHeight), Math.max(box.left, 0)];
    for (let parent = element.parentElement; parent; parent = parent.parentElement) {
        const style = getComputedStyle(parent);
        if (/auto|scroll|hidden|clip/.test(`${style.overflowX} ${style.overflowY}`)) {
            const clip = parent.getBoundingClientRect();
            [top, right, bottom, left] = [Math.max(top, clip.top), Math.min(right, clip.right), Math.min(bottom, clip.bottom), Math.max(left, clip.left)];
        }
    }
    if (bottom - top < 1 || right - left < 1) {
        return false;
    }
    const hit = document.elementsFromPoint((left + right) / 2, (top + bottom) / 2).find((node) => !bubble.contains(node));

    return !hit || element.contains(hit);
}

// Its side first; the opposite one when it doesn't fit there. Kept inside the window either way. Hidden once its
// element is scrolled out of view, rather than left pointing at nothing.
function position(root) {
    const bubble = bubbleOf(root);
    if (!inView(targetOf(root), bubble)) {
        hide({ keepPending: true });

        return;
    }
    const box = targetOf(root).getBoundingClientRect();
    const width = bubble.offsetWidth;
    const height = bubble.offsetHeight;
    const rtl = getComputedStyle(root).direction === 'rtl';
    let side = root.dataset.placement ?? 'top';
    if (side === 'start' || side === 'end') {
        side = (side === 'end') !== rtl ? 'right' : 'left';
    }
    const room = { top: box.top, bottom: window.innerHeight - box.bottom, left: box.left, right: window.innerWidth - box.right };
    const needs = side === 'top' || side === 'bottom' ? height + GAP : width + GAP;
    const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side];
    if (room[side] < needs && room[opposite] > room[side]) {
        side = opposite;
    }
    let left;
    let top;
    if (side === 'top' || side === 'bottom') {
        left = box.left + box.width / 2 - width / 2;
        top = side === 'top' ? box.top - GAP - height : box.bottom + GAP;
    } else {
        left = side === 'left' ? box.left - GAP - width : box.right + GAP;
        top = box.top + box.height / 2 - height / 2;
    }
    const placedLeft = Math.min(Math.max(left, VIEWPORT_EDGE), window.innerWidth - width - VIEWPORT_EDGE);
    const placedTop = Math.min(Math.max(top, VIEWPORT_EDGE), window.innerHeight - height - VIEWPORT_EDGE);
    bubble.style.left = `${placedLeft}px`;
    bubble.style.top = `${placedTop}px`;
    bubble.dataset.side = side;

    // The tail points at the element's middle, even when the tooltip was pushed in from the window's edge, and keeps
    // clear of the rounded corners.
    const arrow = bubble.querySelector('[data-tooltip-arrow]');
    if (arrow) {
        const size = arrow.offsetWidth;
        const along = (start, length, middle) => `${Math.min(Math.max(middle - start - size / 2, ARROW_INSET), length - size - ARROW_INSET)}px`;
        const across = side === 'top' || side === 'bottom';
        arrow.style.left = across ? along(placedLeft, width, box.left + box.width / 2) : '';
        arrow.style.top = across ? '' : along(placedTop, height, box.top + box.height / 2);
    }
}

function show(root) {
    clearTimeout(showTimer);
    clearTimeout(hideTimer);
    if (open === root) {
        return;
    }
    hide();
    const bubble = bubbleOf(root);
    if (!bubble?.textContent.trim()) {
        return;
    }
    link(root);
    bubble.showPopover();
    position(root);
    open = root;
}

// keepPending: closing one that went out of view leaves the next one, already on its way, to show.
function hide({ keepPending = false } = {}) {
    if (!keepPending) {
        clearTimeout(showTimer);
    }
    clearTimeout(hideTimer);
    if (open) {
        const bubble = bubbleOf(open);
        if (bubble?.matches(':popover-open')) {
            bubble.hidePopover();
        }
        open = null;
    }
}

const later = (root) => {
    clearTimeout(hideTimer);
    hideTimer = setTimeout(() => open === root && hide(), HIDE_AFTER_MS);
};

// Pointer: a short rest before it shows, so moving across a toolbar doesn't flash every tooltip. Moving onto the
// tooltip keeps it (its text can be selected, and magnified screens need to reach it).
document.addEventListener('pointerover', (event) => {
    if (event.pointerType === 'touch') {
        return;
    }
    const root = event.target.closest?.(ROOT);
    if (!root) {
        return;
    }
    clearTimeout(hideTimer);
    if (open !== root) {
        clearTimeout(showTimer);
        showTimer = setTimeout(() => show(root), open ? 0 : SHOW_AFTER_MS);
    }
});

document.addEventListener('pointerout', (event) => {
    const root = event.target.closest?.(ROOT);
    if (root && !root.contains(event.relatedTarget)) {
        clearTimeout(showTimer);
        later(root);
    }
});

// Keyboard focus shows it at once; a click's focus doesn't (the pointer already did, or it's a tap).
document.addEventListener('focusin', (event) => {
    const root = event.target.closest?.(ROOT);
    if (root && event.target.matches(':focus-visible')) {
        show(root);
    }
});

document.addEventListener('focusout', (event) => {
    const root = event.target.closest?.(ROOT);
    if (root && !root.contains(event.relatedTarget)) {
        later(root);
    }
});

// Esc hides it without moving focus, and a press means the person has moved on.
document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape' && open) {
        hide();
    }
});
document.addEventListener('pointerdown', () => hide());

window.addEventListener('scroll', () => open && position(open), true);
window.addEventListener('resize', () => open && position(open));

linkAll();

// Tooltips added to the page later link themselves up, and a Livewire render, which puts back the server's markup,
// gets the describedby back.
new MutationObserver((records) => {
    for (const node of records.flatMap((record) => [...record.addedNodes])) {
        if (node instanceof Element) {
            linkAll(node);
        }
    }
}).observe(document.documentElement, { childList: true, subtree: true });

// The bubble keeps its place through a render (wire:ignore.self), but the tail inside it doesn't: put it back too.
const hook = (Livewire) => Livewire.hook('morphed', ({ el }) => {
    linkAll(el);
    if (open) {
        position(open);
    }
});
if (window.Livewire) {
    hook(window.Livewire);
} else {
    document.addEventListener('livewire:init', () => hook(window.Livewire));
}
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Container\Attributes\Scoped;
use LogicException;

/**
 * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
 * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
 */
#[Scoped]
final class ElementIds
{
    /** @var array<string, true> */
    private array $used = [];

    /**
     * Reserves an id for this response.
     *
     * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
     * break that reference; a duplicate throws instead, which surfaces in development and tests.
     */
    public function claim(string $id, bool $explicit = false): string
    {
        if (!isset($this->used[$id])) {
            return $this->reserve($id);
        }

        if ($explicit) {
            throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
        }

        $suffix = 2;
        while (isset($this->used["{$id}-{$suffix}"])) {
            $suffix++;
        }

        return $this->reserve("{$id}-{$suffix}");
    }

    private function reserve(string $id): string
    {
        $this->used[$id] = true;

        return $id;
    }
}