Skip to content
Bladewell

<x-widget.popover>

Popover Blade component for Laravel and Livewire

A panel of anything beside a button: a note, a short form, a profile card, links. On the native popover, so a table or a modal never clips it; flips above when there's no room below. Focus moves into it as it opens and back as it closes, and Esc, a click outside or Tab past its end closes it. popover.open(), close() and toggle() from scripts, and popover-open and popover-close events from Livewire.

php artisan bladewell:add popover
Also adds
Button, Icon

Usage

Livewire

Inside a Livewire component, an open popover stays open through every render, with focus where it was, so wire:click and wire:model inside it work as anywhere else. Open and close it from the component with $this->dispatch('popover-open', id: 'filters') and 'popover-close', using the trigger's id.

Blade
<x-widget.popover id="filters" trigger="Filters" title="Filters">
    <x-widget.button wire:click="clearFilters" size="sm" variant="neutral">Clear filters</x-widget.button>
</x-widget.popover>

Examples

Basic

With only a label, the trigger is an icon button. The panel takes anything; text alone gets focus itself, so screen readers read it from the top. Esc, a click outside or Tab closes it, and focus goes back to the button. For a menu of actions, use the dropdown.

Transfer fee: $1.50

Show code
Blade
<p class="flex items-center gap-2 text-sm">
    Transfer fee: $1.50
    <x-widget.popover label="About the transfer fee" size="sm" variant="tertiary">
        <p>A flat fee for each transfer, whatever the amount. Transfers between your own wallets are free.</p>
    </x-widget.popover>
</p>

With title

title adds a heading that names the panel for screen readers. Focus moves to its first control; data-popover-close on a button closes it. align lines the panel up with the trigger's start, center or end.

Show code
Blade
<x-widget.popover id="share-report" trigger="Share" title="Share this report" align="start">
    <p class="text-foreground/70">Anyone with the link can view it. It expires in 7 days.</p>
    <div class="mt-3 flex items-center gap-2">
        <span class="bg-field min-w-0 flex-1 truncate rounded-xl px-3 py-2 text-xs">https://example.com/r/q3-2026</span>
        <x-widget.button size="sm" data-popover-close>Done</x-widget.button>
    </div>
</x-widget.popover>

Custom trigger

A trigger slot takes your own markup: here a name and initials. Keep links and buttons out of it, since the trigger is one button; they go in the panel. width sets the panel's width: sm, md or lg.

Show code
Blade
<x-widget.popover label="Amelia Chen's profile" width="sm" align="start">
    <x-slot:trigger>
        <span class="bg-primary/10 text-primary grid size-9 place-items-center rounded-full text-sm font-semibold" aria-hidden="true">AC</span>
        <span class="text-sm font-medium">Amelia Chen</span>
    </x-slot:trigger>

    <p class="font-semibold">Amelia Chen</p>
    <p class="text-foreground/70 text-xs">Finance lead · Singapore</p>
    <div class="mt-3 flex flex-col gap-1">
        <a href="#profile" class="text-link hover:text-link-hover underline-offset-4 hover:underline">View profile</a>
        <a href="#message" class="text-link hover:text-link-hover underline-offset-4 hover:underline">Send a message</a>
    </div>
</x-widget.popover>

Props

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

<x-widget.popover>

Prop Default Description
id null The trigger button's id; scripts open the popover by it (popover.open('{id}')), so it must be unique. Made up when left out.
trigger null Text for a button with a chevron, or a trigger slot with your own markup. Without either, the trigger is an icon-only button.
label null The trigger's name for screen readers. Required for the icon-only trigger.
icon 'info' The icon-only trigger's icon.
variant 'neutral' The trigger's look, as on the button: primary, secondary, tertiary, danger, neutral or link.
size 'md' The trigger's size: sm, md or lg.
title null A heading at the top of the panel, which also names it for screen readers. Without one, the trigger names it.
width 'md' How wide the panel is: sm, md or lg.
align 'start' Which edge of the trigger the panel lines up with: start (left in left-to-right pages), center or end.

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add popover 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/popover/index.blade.php Show
index.blade.php
@props([
    // The trigger button's id; scripts open the popover by it (popover.open('{id}')), so it must be unique. Made up when
    // left out.
    'id' => null,
    // Text for a button with a chevron, or a trigger slot with your own markup. Without either, the trigger is an
    // icon-only button.
    'trigger' => null,
    // The trigger's name for screen readers. Required for the icon-only trigger.
    'label' => null,
    // The icon-only trigger's icon.
    'icon' => 'info',
    // The trigger's look, as on the button: primary, secondary, tertiary, danger, neutral or link.
    'variant' => 'neutral',
    // The trigger's size: sm, md or lg.
    'size' => 'md',
    // A heading at the top of the panel, which also names it for screen readers. Without one, the trigger names it.
    'title' => null,
    // How wide the panel is: sm, md or lg.
    'width' => 'md',
    // Which edge of the trigger the panel lines up with: start (left in left-to-right pages), center or end.
    'align' => 'start',
])

@php
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($align, ['start', 'center', 'end'], true)) {
        throw new \InvalidArgumentException("Unknown align [{$align}] for <x-widget.popover>. Use one of: start, center, end.");
    }
    $widths = ['sm' => 'w-64', 'md' => 'w-80', 'lg' => 'w-96'];
    if (! array_key_exists($width, $widths)) {
        throw new \InvalidArgumentException("Unknown width [{$width}] for <x-widget.popover>. Use one of: sm, md, lg.");
    }
    // The trigger's look, passed to the button; checked here too, so the error names the tag that was written.
    if (! in_array($variant, ['primary', 'secondary', 'tertiary', 'danger', 'neutral', 'link'], true)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.popover>. Use one of: primary, secondary, tertiary, danger, neutral, link.");
    }
    // Scripts open it by the trigger's id, so an explicit id must be unique; a derived one gets a suffix.
    $id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'popover', explicit: $id !== null);
    $panelId = "{$id}-panel";
    $custom = $trigger instanceof \Illuminate\View\ComponentSlot;
    $triggerAttributes = new \Illuminate\View\ComponentAttributeBag([
        'id' => $id,
        'popovertarget' => $panelId,
        'aria-haspopup' => 'dialog',
        'aria-expanded' => 'false',
        'aria-controls' => $panelId,
        'data-popover-trigger' => '',
        // The script keeps aria-expanded in step with the panel; a Livewire render would put back the server's "false".
        'wire:ignore.self' => '',
    ]);
@endphp

{{--
    A panel of anything (text, a form, links) beside a button, on the native popover: the browser opens it from
    popovertarget and closes it on Esc or a click outside, and it sits in the top layer, so a table's overflow or a modal
    can't clip it. resources/js/widget/popover places it, moves focus into it and back, and closes it when focus leaves.
    A non-modal dialog: the page stays usable behind it. For a menu of actions, see the dropdown.
--}}
<div data-popover data-no-row-click data-align="{{ $align }}" {{ $attributes->class(['relative inline-flex']) }}>
    @if ($custom)
        {{-- It is one button, so keep links and other buttons out of the slot. --}}
        <button
            type="button"
            @if ($label) aria-label="{{ $label }}" @endif
            {{ $trigger->attributes->class(['focus-visible:ring-primary inline-flex items-center gap-2 rounded-xl text-start outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface'])->merge($triggerAttributes->getAttributes()) }}
        >{{ $trigger }}</button>
    @elseif (is_string($trigger) && $trigger !== '')
        <x-widget.button :variant="$variant" :size="$size" icon-end="chevron-down" :attributes="$triggerAttributes">{{ $trigger }}</x-widget.button>
    @else
        <x-widget.button :variant="$variant" :size="$size" :icon="$icon" :label="$label" :attributes="$triggerAttributes" />
    @endif

    <div
        id="{{ $panelId }}"
        popover
        role="dialog"
        tabindex="-1"
        {{-- The script places the open panel (inline top/left, data-side); a Livewire render would wipe that and it would
             jump. What's inside still updates. --}}
        wire:ignore.self
        @if ($title) aria-labelledby="{{ $panelId }}-title" @else aria-labelledby="{{ $id }}" @endif
        data-popover-panel
        @class([
            // hidden until open: a display utility on a popover beats the browser's own rule that hides it while closed.
            'border-line bg-surface text-foreground fixed inset-auto m-0 hidden max-h-[min(32rem,calc(100dvh-2rem))] max-w-[calc(100vw-1rem)] flex-col overflow-y-auto overscroll-contain rounded-2xl border p-4 text-sm shadow-lg outline-none open:flex',
            $widths[$width],
            // Fades and grows in from the trigger's side (data-side, set by the script when it flips above).
            'origin-top opacity-0 scale-95 transition-[opacity,scale,display,overlay] transition-discrete duration-150 open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-95 data-[side=top]:origin-bottom motion-reduce:transition-none',
        ])
    >
        @if ($title)
            <h2 id="{{ $panelId }}-title" class="text-foreground mb-2 text-base font-semibold">{{ $title }}</h2>
        @endif
        {{ $slot }}
    </div>
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/js/widget/popover/index.js Show
index.js
// Drives <x-widget.popover>. Opening, Esc and click-outside come from the native popover (popovertarget); this places
// the panel beside its trigger (flipping above when there's no room below), moves focus into it as it opens and back to
// the trigger as it closes, closes it when focus leaves it (Tab past its end), and closes it on [data-popover-close]
// inside. From JS: popover.open(id), popover.close(id), popover.toggle(id), with the trigger's id.
// Events on the panel: popover:opened and popover:closed. Delegated from `document`, so popovers added later (a
// Livewire render, fetched HTML) work without setting up.

const VIEWPORT_EDGE = 8;
const GAP = 6;
const PANEL = '[data-popover-panel]';
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

let openPanel = null;

const parts = (panel) => {
    const root = panel.closest('[data-popover]');

    return { root, trigger: root.querySelector('[data-popover-trigger]') };
};
const panelOf = (id) => {
    const trigger = document.getElementById(id);

    return trigger?.matches('[data-popover-trigger]') ? document.getElementById(trigger.getAttribute('aria-controls')) : null;
};

// Whether any of the trigger 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).
function inView(trigger, panel) {
    const box = trigger.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 = trigger.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((element) => !panel.contains(element));

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

function position() {
    if (!openPanel) {
        return;
    }
    const panel = openPanel;
    const { root, trigger } = parts(panel);
    // In the top layer nothing clips it: with its trigger scrolled out of view it would point at nothing. Close it.
    if (!inView(trigger, panel)) {
        panel.hidePopover();

        return;
    }
    const box = trigger.getBoundingClientRect();
    const width = panel.offsetWidth;
    const height = panel.offsetHeight;

    // start and end follow the reading direction: end is the left edge in right-to-left pages.
    const rtl = getComputedStyle(root).direction === 'rtl';
    const align = root.dataset.align;
    const left = align === 'center'
        ? box.left + box.width / 2 - width / 2
        : (align === 'end') !== rtl ? box.right - width : box.left;
    panel.style.left = `${Math.min(Math.max(left, VIEWPORT_EDGE), window.innerWidth - width - VIEWPORT_EDGE)}px`;

    // Below by default; above when it fits there and not below. Fitting neither, the roomier side wins and the panel
    // scrolls in that room.
    panel.style.maxHeight = '';
    const roomBelow = window.innerHeight - VIEWPORT_EDGE - (box.bottom + GAP);
    const roomAbove = box.top - GAP - VIEWPORT_EDGE;
    const up = height > roomBelow && (height <= roomAbove || roomAbove > roomBelow);
    const room = up ? roomAbove : roomBelow;
    if (height > room) {
        panel.style.maxHeight = `${Math.max(room, 0)}px`;
    }
    panel.style.top = `${up ? box.top - GAP - Math.min(height, room) : box.bottom + GAP}px`;
    panel.dataset.side = up ? 'top' : 'bottom';
}

// Popover toggle events don't bubble, so listen in the capture phase.
const focusTargets = new WeakMap();

// Focus goes into it as a dialog's does: to its first control, or to the panel itself when it's only text (read from
// the top). Marked with autofocus just before it opens, so the browser focuses it as part of opening: a script calling
// focus() as the toggle event runs is ignored, while the panel is still fading in. It starts transparent and is placed
// as it opens, so it never shows at the popover's default spot (the middle of the screen).
document.addEventListener('beforetoggle', (event) => {
    const panel = event.target;
    if (panel.matches?.(PANEL) && event.newState === 'open') {
        const target = panel.querySelector('[data-autofocus]') ?? panel.querySelector(FOCUSABLE) ?? panel;
        focusTargets.set(panel, target);
        if (!target.hasAttribute('autofocus')) {
            target.setAttribute('autofocus', '');
            target.dataset.popoverAutofocus = '';
        }
    }
}, true);

document.addEventListener('toggle', (event) => {
    const panel = event.target;
    if (!panel.matches?.(PANEL)) {
        return;
    }
    const { trigger } = parts(panel);
    const open = event.newState === 'open';
    trigger.setAttribute('aria-expanded', String(open));

    if (open) {
        openPanel = panel;
        position();
        // Only for this opening: the content may change before the next.
        panel.querySelectorAll('[data-popover-autofocus]').forEach((element) => {
            element.removeAttribute('autofocus');
            delete element.dataset.popoverAutofocus;
        });
        if (panel.hasAttribute('data-popover-autofocus')) {
            panel.removeAttribute('autofocus');
            delete panel.dataset.popoverAutofocus;
        }
        // The browser only autofocuses for a person's click or key. Opened by a script (popover.open, a Livewire event),
        // focus goes in here instead, frame by frame until it takes: while the panel is still fading in, focus() can be
        // ignored. It stops once focus is inside, the panel closes, or after half a second.
        let frames = 0;
        const into = () => {
            if (!panel.matches(':popover-open') || panel.contains(document.activeElement)) {
                return;
            }
            focusTargets.get(panel)?.focus({ preventScroll: true });
            if (!panel.contains(document.activeElement) && ++frames < 30) {
                requestAnimationFrame(into);
            }
        };
        into();
        window.addEventListener('scroll', position, true);
        window.addEventListener('resize', position);
        panel.dispatchEvent(new CustomEvent('popover:opened', { bubbles: true }));

        return;
    }

    if (openPanel === panel) {
        openPanel = null;
        window.removeEventListener('scroll', position, true);
        window.removeEventListener('resize', position);
    }
    // Esc, a close button or a script leaves focus nowhere or inside; put it back on the trigger. A click elsewhere, or
    // Tab out of the panel, keeps focus where it went.
    if (document.activeElement === document.body || panel.contains(document.activeElement)) {
        trigger.focus({ preventScroll: true });
    }
    panel.dispatchEvent(new CustomEvent('popover:closed', { bubbles: true }));
}, true);

// Focus leaving the panel (Tab past its last control, or Shift+Tab back to the trigger) closes it, as a click outside
// does. Without this, the panel would stay open behind whatever has focus now.
document.addEventListener('focusout', (event) => {
    const panel = event.target.closest?.(PANEL);
    if (panel?.matches(':popover-open') && event.relatedTarget && !panel.contains(event.relatedTarget)) {
        panel.hidePopover();
    }
});

document.addEventListener('click', (event) => {
    const closer = event.target.closest?.(`${PANEL} [data-popover-close]`);
    closer?.closest(PANEL).hidePopover();
});

function open(id) {
    const panel = panelOf(id);
    if (panel && !panel.matches(':popover-open')) {
        panel.showPopover();
    }
}

function close(id) {
    const panel = panelOf(id);
    if (panel?.matches(':popover-open')) {
        panel.hidePopover();
    }
}

export const popover = {
    open,
    close,
    toggle: (id) => (panelOf(id)?.matches(':popover-open') ? close(id) : open(id)),
};

// From a Livewire component ($this->dispatch('popover-close', id: 'filters')) or any script.
window.addEventListener('popover-open', (event) => open(event.detail?.id));
window.addEventListener('popover-close', (event) => close(event.detail?.id));
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;
    }
}