Skip to content
LarawellUi

<x-widget.tooltip-cursor>

Tooltip cursor

A tooltip that follows the pointer over its element, for charts, maps and large areas where a label pinned to one edge would be far from what's pointed at. Moves to the other side of the pointer near the window's edges; with the keyboard it sits under the focused element. Hidden on Esc, read out by screen readers, never clipped. For a label pinned to an element, see the tooltip. Works inside Livewire components.

php artisan larawell:add tooltip-cursor
Also adds
Icon

Usage

Livewire

Inside a Livewire component the text updates with each render, a showing one keeps following the pointer, and it stays linked to its element for screen readers.

Blade
<div class="flex h-40 items-end gap-2" wire:poll.10s="refresh">
    @foreach ($days as $day)
        <x-widget.tooltip-cursor wire:key="day-{{ $day->date }}" :text="$day->label.': '.$day->orders.' orders'" class="h-full flex-1 items-end">
            {{-- your bar --}}
        </x-widget.tooltip-cursor>
    @endforeach
</div>

Examples

Area

Around anything larger: a card, an image, a region of a map. It follows the pointer anywhere over it, and keeps to the window, moving to the other side of the pointer near its right and bottom edges. Around a link or a button, keyboard focus shows it under the element.

Photo
Show code
Blade
<x-widget.tooltip-cursor text="Click to see the full-size photo">
    <a href="#photo" class="bg-field text-foreground/60 focus-visible:ring-primary grid h-40 w-72 place-items-center rounded-2xl outline-none focus-visible:ring-2">
        <x-widget.icon name="image" class="size-8" />
        <span class="sr-only">Photo</span>
    </a>
</x-widget.tooltip-cursor>

Chart

The usual place for it: the bars of a chart, each with its own reading that follows the pointer along it. A bar can't take focus, so the tooltip makes it focusable and names it with its text, so keyboard and screen reader users get each reading too.

Show code
Blade
<div class="flex h-40 items-end gap-2" role="list" aria-label="Orders this week">
    {{-- Each bar's height as a class written out in full, so Tailwind sees it: a share of the busiest day. --}}
    @foreach ([
        'Mon' => ['orders' => 12, 'height' => 'h-[39%]'], 'Tue' => ['orders' => 19, 'height' => 'h-[61%]'],
        'Wed' => ['orders' => 8, 'height' => 'h-[26%]'], 'Thu' => ['orders' => 23, 'height' => 'h-[74%]'],
        'Fri' => ['orders' => 31, 'height' => 'h-full'], 'Sat' => ['orders' => 17, 'height' => 'h-[55%]'],
        'Sun' => ['orders' => 6, 'height' => 'h-[19%]'],
    ] as $day => $bar)
        <x-widget.tooltip-cursor :text="$day.': '.$bar['orders'].' orders'" role="listitem" class="h-full flex-1 items-end">
            <span class="bg-primary/80 hover:bg-primary block w-full rounded-t-md transition-colors {{ $bar['height'] }}"></span>
        </x-widget.tooltip-cursor>
    @endforeach
</div>

Props

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

<x-widget.tooltip-cursor>

Prop Default Description
text Required What it says: a short label or reading for what's under the pointer ("Tuesday: 14 orders").
id null Defaults to one made for it; the element inside points at it with aria-describedby.

Source

What larawell:add tooltip-cursor 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-cursor/index.blade.php Show
index.blade.php
@props([
    // What it says: a short label or reading for what's under the pointer ("Tuesday: 14 orders").
    'text',
    // Defaults to one made for it; the element inside points at it with aria-describedby.
    'id' => null,
])

@php
    $id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'tooltip-cursor', explicit: $id !== null);
@endphp

{{--
    Wraps one element, and follows the pointer while it's over it: just below and after the pointer, moving to the
    other side of it near the window's edges. With the keyboard there's no pointer, so it sits under the focused element
    instead. It never takes the pointer (it would be in the way of what's under it) and Esc hides it. It sits in the top
    layer (a popover), so a table or a modal can't clip it. resources/js/widget/tooltip-cursor points the element's
    aria-describedby at it, so screen readers read it too.
--}}
<span data-tooltip-cursor="{{ $id }}" {{ $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 moves it with the pointer (inline top and left); a Livewire render would wipe that. The text still updates. --}}
        wire:ignore.self
        data-tooltip-cursor-bubble
        class="bg-foreground text-surface pointer-events-none fixed inset-auto m-0 hidden max-w-64 rounded-lg px-2.5 py-1.5 text-xs leading-snug font-medium shadow-lg open:block"
    >{{ $text }}</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-cursor/index.js Show
index.js
// Drives <x-widget.tooltip-cursor>: a tooltip that follows the pointer over its element, just below and after it, and
// moves to the other side of the pointer near the window's edges. Keyboard focus puts it under the element instead.
// Esc hides it. Points the element's aria-describedby at it for screen readers. Delegated from `document`, so ones
// added later (a Livewire render, fetched HTML) work without setting up.

const ROOT = '[data-tooltip-cursor]';
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
const SHOW_AFTER_MS = 150;
// Leaving one for the next within this long (the bars of a chart) shows the next at once.
const STILL_READING_MS = 300;
// From the pointer to the tooltip's corner: clear of the cursor arrow, which points down and to the right.
const OFFSET = 14;
const GAP = 8;
const VIEWPORT_EDGE = 8;

let open = null;
let showTimer = null;
let frame = null;
let pointer = null;
let closedAt = 0;

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

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

// As the tooltip does: aria-describedby on the element (its own ids kept), a title saying the same dropped, and the
// tooltip's text as the name of an icon with no text of its own.
function link(root) {
    const bubble = bubbleOf(root);
    const target = targetOf(root);
    if (!bubble || !target) {
        return;
    }
    if (target === root && !root.hasAttribute('tabindex')) {
        root.tabIndex = 0;
    }
    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);
}

// After the pointer and below it, or on the other side of it where that would run off the window. Without a pointer
// (keyboard focus), centred under the element, or over it without room below.
function place(root) {
    const bubble = bubbleOf(root);
    const width = bubble.offsetWidth;
    const height = bubble.offsetHeight;
    let left;
    let top;
    if (pointer) {
        const rtl = getComputedStyle(root).direction === 'rtl';
        const after = rtl ? pointer.x - OFFSET - width : pointer.x + OFFSET;
        const before = rtl ? pointer.x + OFFSET : pointer.x - OFFSET - width;
        left = after >= VIEWPORT_EDGE && after + width <= window.innerWidth - VIEWPORT_EDGE ? after : before;
        top = pointer.y + OFFSET + height <= window.innerHeight - VIEWPORT_EDGE ? pointer.y + OFFSET : pointer.y - OFFSET - height;
    } else {
        const box = targetOf(root).getBoundingClientRect();
        left = box.left + box.width / 2 - width / 2;
        top = box.bottom + GAP + height <= window.innerHeight - VIEWPORT_EDGE ? box.bottom + GAP : box.top - GAP - height;
    }
    bubble.style.left = `${Math.min(Math.max(left, VIEWPORT_EDGE), window.innerWidth - width - VIEWPORT_EDGE)}px`;
    bubble.style.top = `${Math.min(Math.max(top, VIEWPORT_EDGE), window.innerHeight - height - VIEWPORT_EDGE)}px`;
}

function show(root) {
    clearTimeout(showTimer);
    if (open === root) {
        return;
    }
    hide();
    const bubble = bubbleOf(root);
    if (!bubble?.textContent.trim()) {
        return;
    }
    link(root);
    bubble.showPopover();
    place(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);
    }
    if (open) {
        const bubble = bubbleOf(open);
        if (bubble?.matches(':popover-open')) {
            bubble.hidePopover();
        }
        open = null;
        closedAt = performance.now();
    }
}

// The pointer: a short rest before it shows, then it follows, one move per frame.
document.addEventListener('pointerover', (event) => {
    if (event.pointerType === 'touch') {
        return;
    }
    const root = event.target.closest?.(ROOT);
    if (root && open !== root) {
        pointer = { x: event.clientX, y: event.clientY };
        clearTimeout(showTimer);
        const reading = open || performance.now() - closedAt < STILL_READING_MS;
        showTimer = setTimeout(() => show(root), reading ? 0 : SHOW_AFTER_MS);
    }
});

document.addEventListener('pointermove', (event) => {
    if (event.pointerType === 'touch') {
        return;
    }
    pointer = { x: event.clientX, y: event.clientY };
    if (open && !frame) {
        frame = requestAnimationFrame(() => {
            frame = null;
            if (open) {
                place(open);
            }
        });
    }
});

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

// Keyboard focus: no pointer to follow, so it goes under the element.
document.addEventListener('focusin', (event) => {
    const root = event.target.closest?.(ROOT);
    if (root && event.target.matches(':focus-visible')) {
        pointer = null;
        show(root);
    }
});

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

document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape' && open) {
        hide();
    }
});
document.addEventListener('pointerdown', () => hide());
// A scroll can take the element out of view, or out from under a pointer that hasn't moved (no pointerout fires then):
// hide it either way, rather than leave it describing something no longer there.
window.addEventListener('scroll', () => {
    if (!open) {
        return;
    }
    const bubble = bubbleOf(open);
    const under = pointer ? document.elementsFromPoint(pointer.x, pointer.y).find((node) => !bubble.contains(node)) : null;
    if (!inView(targetOf(open), bubble) || (pointer && !open.contains(under ?? null))) {
        hide({ keepPending: true });
    } else if (!pointer) {
        place(open);
    }
}, true);

linkAll();

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 });

// A Livewire render puts back the server's markup: link it up again, and keep a showing one where it was.
const hook = (Livewire) => Livewire.hook('morphed', ({ el }) => {
    linkAll(el);
    if (open) {
        place(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;
    }
}