Skip to content
Brok UI

Loading…

No results

Hover Card

Open source

Shows rich content in a card when a trigger is hovered or focused. A `preview` slot turns it into an inline component preview with a stage and caption; the card opens below or above the trigger.

Version
v1.5.2
Stability
stable
License
MIT
Related
Tooltip
Popover
Link Preview

Preview

Created by

Jane Doe @janedoe

Product designer building accessible UI components.

Joined December 2021

a few minutes ago.
Side
previews.components.hover-card.default.blade.php Blade
{{-- A mention in running text: hover or focus the handle to see who it is. The
     wrapper is a div, not a <p>: the card panel is block content, and a <p>
     would be auto-closed by the parser before it. --}}
<div class="text-sm text-muted-foreground">
    <span>{{ __('Created by') }}</span>
    <x-ui.hover-card>
        <x-ui.hover-card.trigger class="font-medium text-foreground">@janedoe</x-ui.hover-card.trigger>
        <x-ui.hover-card.content class="w-72">
            <div class="flex gap-3">
                <x-ui.avatar fallback="JD" size="md" class="shrink-0" />
                <div class="min-w-0 space-y-1">
                    <p class="text-sm font-semibold leading-tight text-popover-foreground">{{ __('Jane Doe') }} <span class="font-normal text-muted-foreground">@janedoe</span></p>
                    <div class="text-sm text-muted-foreground">{{ __('Product designer building accessible UI components.') }}</p>
                    <p class="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
                        <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
                        {{ __('Joined December 2021') }}
                    </p>
                </div>
            </div>
        </x-ui.hover-card.content>
    </x-ui.hover-card>
    <span>{{ __('a few minutes ago.') }}</span>
</div>
Top Current
Right Current
Bottom Current
Left Current

Installation

terminal
php artisan ui:add hover-card

Note

This component ships an Alpine behavior module at resources/js/ui/hover-card.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './hover-card.js';

Registry contract

php artisan ui:add hover-card 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.

  • blade resources/views/components/ui/hover-card.blade.php
  • blade resources/views/components/ui/hover-card/trigger.blade.php
  • blade resources/views/components/ui/hover-card/content.blade.php
  • js resources/js/ui/hover-card.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

hover-card.md
# Brok UI: Hover Card (`hover-card`)

Shows rich content in a card when a trigger is hovered or focused. A `preview` slot turns it into an inline component preview with a stage and caption; the card opens below or above the trigger.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add hover-card
```

## Usage

```blade
{{-- A mention in running text: hover or focus the handle to see who it is. The
     wrapper is a div, not a <p>: the card panel is block content, and a <p>
     would be auto-closed by the parser before it. --}}
<div class="text-sm text-muted-foreground">
    <span>{{ __('Created by') }}</span>
    <x-ui.hover-card>
        <x-ui.hover-card.trigger class="font-medium text-foreground">@janedoe</x-ui.hover-card.trigger>
        <x-ui.hover-card.content class="w-72">
            <div class="flex gap-3">
                <x-ui.avatar fallback="JD" size="md" class="shrink-0" />
                <div class="min-w-0 space-y-1">
                    <p class="text-sm font-semibold leading-tight text-popover-foreground">{{ __('Jane Doe') }} <span class="font-normal text-muted-foreground">@janedoe</span></p>
                    <div class="text-sm text-muted-foreground">{{ __('Product designer building accessible UI components.') }}</p>
                    <p class="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
                        <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
                        {{ __('Joined December 2021') }}
                    </p>
                </div>
            </div>
        </x-ui.hover-card.content>
    </x-ui.hover-card>
    <span>{{ __('a few minutes ago.') }}</span>
</div>
```

## Props

- `group` (string|null, default `null`) — Opt-in coordination key set on the root component: opening a hover card closes any other hover card sharing the same group name. Leave unset for standalone hover cards.
- `label` (string|null, default `null`) — Caption title shown under the preview stage when the preview slot is used.
- `description` (string|null, default `null`) — Caption description shown under the label when the preview slot is used.
- `side` (top|bottom, default `bottom`) — Which side of the trigger the card opens on.
- `name` (string|null, default `null`) — A name the window events hover-card:open, hover-card:close and hover-card:toggle address with { name }, for a keyboard peek (a list row's Space handler). The card opens at once and focus stays where it is.
- `openDelay` (int, default `600`) — Hover delay in milliseconds before the card opens.
- `closeDelay` (int, default `250`) — Delay in milliseconds before the card closes after the pointer leaves.
- `lazy` (bool, default `false`) — Declared by @props in the registry Blade source.
- `flush` (bool, default `false`) — Declared by @props in the registry Blade source.

## Use when

- Use for lightweight contextual details or actions near a trigger.
- Revealing rich, non-critical content (a profile summary, a component preview) when a trigger is hovered or focused.
- Needing an inline component preview with a stage and caption via the preview slot.

## Avoid when

- Do not use for long content, required reading, or complex interaction flows.
- The content is plain short text; use tooltip instead.
- The content must be reachable without hover or focus, such as on touch-only devices with no equivalent tap path; use popover.

## Anti-patterns

- Required reading in a popover
- Complex forms in fragile anchored overlays

## Rules

- Use the `<brok:hover-card>` tag (or `<x-ui.hover-card>`) 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/hover-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/hover-card.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

grouped.blade.php Blade
{{--
    Two mentions sharing one `group`: focusing or hovering one card closes the
    other, so scanning past several mentions never leaves two cards open at
    once. `lazy` defers each card's content until it is actually opened; the
    nested `x-data` counter proves that lazily-mounted content still
    initialises its own Alpine behaviour correctly.
--}}
<div class="text-sm text-muted-foreground">
    {{ __('Reviewed by') }}
    <x-ui.hover-card group="grouped-demo-mentions">
        <x-ui.hover-card.trigger class="font-medium text-foreground">
            {{ __('@alex') }}
        </x-ui.hover-card.trigger>

        <x-ui.hover-card.content lazy id="grouped-demo-card-alex">
            <div x-data="{ count: 0 }" class="space-y-2">
                <p class="text-sm font-semibold text-popover-foreground">{{ __('Alex Rivera') }}</p>
                <div class="text-sm text-muted-foreground">{{ __('Reviews checkout and payments changes.') }}</p>
                <button
                    type="button"
                    @click="count++"
                    class="inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-sm text-foreground hover:bg-muted"
                >
                    {{ __('Clicked') }}: <span x-text="count" class="ms-1 tabular-nums"></span>
                </button>
            </div>
        </x-ui.hover-card.content>
    </x-ui.hover-card>
    {{ __('and') }}
    <x-ui.hover-card group="grouped-demo-mentions">
        <x-ui.hover-card.trigger class="font-medium text-foreground">
            {{ __('@sam') }}
        </x-ui.hover-card.trigger>

        <x-ui.hover-card.content lazy id="grouped-demo-card-sam">
            <div class="space-y-1">
                <p class="text-sm font-semibold text-popover-foreground">{{ __('Sam Okoye') }}</p>
                <div class="text-sm text-muted-foreground">{{ __('Reviews accessibility and design tokens.') }}</p>
            </div>
        </x-ui.hover-card.content>
    </x-ui.hover-card>
    {{ __('a few minutes ago.') }}
</div>
peek.blade.php Blade
{{-- Keyboard peek: focus a task and press Space. The row dispatches
     hover-card:toggle { name }, the named card opens without moving focus,
     and Space again or Escape closes it. Hovering still opens it after
     the delay. --}}
@php
    $tasks = [
        ['id' => 'ATL-12', 'title' => __('Draft the kickoff agenda'), 'status' => __('In progress'), 'owner' => __('Sam Visser')],
        ['id' => 'ATL-14', 'title' => __('Review the contract with the client'), 'status' => __('Todo'), 'owner' => __('Noor Bakker')],
    ];
@endphp

<ul class="w-full max-w-md divide-y divide-border rounded-md border border-border bg-card text-card-foreground" aria-label="{{ __('Tasks') }}">
    @foreach ($tasks as $task)
        <li class="px-4 py-2 text-sm">
            <x-ui.hover-card :name="$task['id']" :open-delay="400" class="block">
                <a
                    href="#{{ $task['id'] }}"
                    x-on:keydown.space.prevent="$dispatch('hover-card:toggle', { name: @js($task['id']) })"
                    aria-keyshortcuts="Space"
                    class="flex min-w-0 items-center gap-3 rounded-sm outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
                >
                    <span class="shrink-0 text-xs text-muted-foreground tabular-nums">{{ $task['id'] }}</span>
                    <span class="min-w-0 truncate font-medium text-foreground">{{ $task['title'] }}</span>
                </a>
                <x-ui.hover-card.content class="w-72">
                    <div class="flex min-w-0 flex-col gap-1">
                        <p class="text-xs text-muted-foreground tabular-nums">{{ $task['id'] }}</p>
                        <p class="text-sm font-medium text-popover-foreground">{{ $task['title'] }}</p>
                        <p class="text-xs text-muted-foreground">{{ $task['status'] }}, {{ $task['owner'] }}</p>
                    </div>
                </x-ui.hover-card.content>
            </x-ui.hover-card>
        </li>
    @endforeach
</ul>
preview.blade.php Blade
{{-- Preview mode: fill the `preview` slot and the card renders a stage for it
     plus an optional caption — an inline component preview in running text. --}}
<div class="mx-auto max-w-md py-10 text-center text-sm text-foreground">
    {{ __('Built entirely with the') }}
    <x-ui.hover-card
        side="top"
        :label="__('Badge')"
        :description="__('A small status pill with token-themed variants.')"
    >
        <span class="font-medium text-primary">badge</span>
        <x-slot:preview>
            <span class="flex gap-2">
                <x-ui.badge>{{ __('Default') }}</x-ui.badge>
                <x-ui.badge variant="success">{{ __('Live') }}</x-ui.badge>
            </span>
        </x-slot:preview>
    </x-ui.hover-card>
    {{ __('primitive: hover or focus the link to preview it.') }}
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
group string | null null Opt-in coordination key set on the root component: opening a hover card closes any other hover card sharing the same group name. Leave unset for standalone hover cards.
label string | null null Caption title shown under the preview stage when the preview slot is used.
description string | null null Caption description shown under the label when the preview slot is used.
side top | bottom bottom Which side of the trigger the card opens on.
name string | null null A name the window events hover-card:open, hover-card:close and hover-card:toggle address with { name }, for a keyboard peek (a list row's Space handler). The card opens at once and focus stays where it is.
openDelay int 600 Hover delay in milliseconds before the card opens.
closeDelay int 250 Delay in milliseconds before the card closes after the pointer leaves.
lazy bool false Declared by @props in the registry Blade source.
flush bool false Declared by @props in the registry Blade source.

Slots

  • default — Trigger content, or the compositional trigger/content parts when not using preview mode.
  • preview — Fills the card's preview stage and switches the root into preview mode, rendering its own trigger and content automatically.
  • x-ui.hover-card.trigger — Installed subcomponent from the registry item.
  • x-ui.hover-card.content — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

hover-card-caption hover-card-content hover-card-preview hover-card-trigger {{ $slotName }}

Behavior

  • Opening and closing are delayed so passing the pointer through the trigger does not flicker the card; the card stays open while hovered.
  • Opt-in `group` coordination closes sibling hover cards sharing the same group name, restoring focus to its own trigger if it was stranded inside the closed card.
  • Opt-in `lazy` content defers and re-defers slot mounting to the card's open state.
  • Peek: window.dispatchEvent(new CustomEvent('hover-card:toggle', { detail: { name: 'ATL-12' } })) opens the named card at once without moving focus, so a focused list row stays the keyboard target; the same event closes it again, and Escape closes an open card.
  • openDelay and closeDelay tune the hover delays; the defaults (600 and 250 ms) are unchanged.
  • 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

Contextual overlay

Show lightweight contextual details or actions.

Use when

  • Use for lightweight contextual details or actions near a trigger.
  • Revealing rich, non-critical content (a profile summary, a component preview) when a trigger is hovered or focused.
  • Needing an inline component preview with a stage and caption via the preview slot.

Avoid when

  • Do not use for long content, required reading, or complex interaction flows.
  • The content is plain short text; use tooltip instead.
  • The content must be reachable without hover or focus, such as on touch-only devices with no equivalent tap path; use popover.

Use instead

  • Inline disclosure
  • Sheet or page for longer content

Anti-patterns

  • Required reading in a popover
  • Complex forms in fragile anchored overlays
Anatomy
root trigger content preview caption
Theming hooks
hover-card-caption hover-card-content hover-card-preview hover-card-trigger {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Escape
Focus
managed
  • The trigger is a real button so assistive tech announces it correctly, and focus opens the card the same way hover does.
  • Escape dismisses the card while it is focused, satisfying the requirement that hover/focus content be dismissible.
  • A peek never moves focus, so arrow keys keep moving through the list; Escape closes it (WCAG 1.4.13). Keep the peek content reachable elsewhere (the row's own page), because a card opened without focus is not announced.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="hover-card-{{ $record->id }}">
    {{-- A mention in running text: hover or focus the handle to see who it is. The
         wrapper is a div, not a <p>: the card panel is block content, and a <p>
         would be auto-closed by the parser before it. --}}
    <div class="text-sm text-muted-foreground">
        <span>{{ __('Created by') }}</span>
        <x-ui.hover-card>
            <x-ui.hover-card.trigger class="font-medium text-foreground">@janedoe</x-ui.hover-card.trigger>
            <x-ui.hover-card.content class="w-72">
                <div class="flex gap-3">
                    <x-ui.avatar fallback="JD" size="md" class="shrink-0" />
                    <div class="min-w-0 space-y-1">
                        <p class="text-sm font-semibold leading-tight text-popover-foreground">{{ __('Jane Doe') }} <span class="font-normal text-muted-foreground">@janedoe</span></p>
                        <div class="text-sm text-muted-foreground">{{ __('Product designer building accessible UI components.') }}</p>
                        <p class="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
                            <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
                            {{ __('Joined December 2021') }}
                        </p>
                    </div>
                </div>
            </x-ui.hover-card.content>
        </x-ui.hover-card>
        <span>{{ __('a few minutes ago.') }}</span>
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:hover-card
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/hover-card.blade.php Blade
@props([
    'group' => null,
    // Preview mode (the former component-preview-tooltip): fill the `preview`
    // slot and the root renders its own trigger from the default slot plus a
    // card with a stage for the preview, an optional `label`/`description`
    // caption, opening on `side` (bottom or top). Without a `preview` slot the
    // root is the plain compositional wrapper for .trigger and .content.
    'label' => null,
    'description' => null,
    'side' => 'bottom',
    // Programmatic open for a keyboard "peek": a name the window events
    // `hover-card:open`, `hover-card:close` and `hover-card:toggle` address
    // with { name } (a list row's Space handler dispatches them). The card
    // opens at once, focus stays where it is, and Escape closes it.
    'name' => null,
    // Hover delays in milliseconds (defaults unchanged: 600 to open, 250 to close).
    'openDelay' => 600,
    'closeDelay' => 250,
])

@php
    // `group` (opt-in, default null): coordinate multiple hover cards so
    // opening one closes any sibling sharing the same group name — see
    // popover.blade.php for the identical rationale. Reduces to the original
    // behaviour byte-for-byte when omitted.
    $options = array_filter([
        'name' => filled($name) ? (string) $name : null,
        'openDelay' => (int) $openDelay !== 600 ? max(0, (int) $openDelay) : null,
        'closeDelay' => (int) $closeDelay !== 250 ? max(0, (int) $closeDelay) : null,
    ], fn ($value) => $value !== null);
    $groupArg = $group !== null && $group !== '' ? json_encode($group) : null;
    // The default call stays `uiHoverCard()` so an existing render is unchanged.
    $dataCall = match (true) {
        $options !== [] => 'uiHoverCard('.($groupArg ?? 'null').', '.\Illuminate\Support\Js::from($options).')',
        $groupArg !== null => 'uiHoverCard('.$groupArg.')',
        default => 'uiHoverCard()',
    };
    // component-preview-tooltip renders this component under its own name; the
    // browser keeps only the first of two identical attributes, so read the
    // override out of the bag and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'hover-card');
@endphp

{{--
    Hover Card. Reveals rich content in a card when its trigger is hovered or
    focused. Opening and closing are delayed so passing the pointer through the
    trigger does not flicker the card; the card stays open while hovered.
--}}
<div
    x-data="{{ $dataCall }}"
    data-slot="{{ $slotName }}"
    @if (filled($name)) data-name="{{ $name }}" @endif
    @mouseenter="openWithDelay()"
    @mouseleave="closeWithDelay()"
    {{ $attributes->except('data-slot')->merge(['class' => 'relative inline-block']) }}
>
@isset ($preview)
    <x-ui.hover-card.trigger>{{ $slot }}</x-ui.hover-card.trigger>
    <x-ui.hover-card.content :side="$side" flush>
        <div data-slot="hover-card-preview" class="flex items-center justify-center border-b border-border bg-muted/40 p-4">
            {{ $preview }}
        </div>
        @if ($label || $description)
            <div data-slot="hover-card-caption" class="p-3">
                @if ($label)
                    <p class="text-xs font-semibold">{{ $label }}</p>
                @endif
                @if ($description)
                    <p class="mt-0.5 text-xs text-muted-foreground">{{ $description }}</p>
                @endif
            </div>
        @endif
    </x-ui.hover-card.content>
@else
    {{ $slot }}
@endisset
</div>
resources/views/components/ui/hover-card/trigger.blade.php Blade
{{--
    A <button> rather than an href-less <a>: an anchor without href has no
    implicit ARIA role, so assistive tech announced this as plain text while the
    underline promised a link. Escape dismisses the card (WCAG 1.4.13, content
    on hover or focus must be dismissible).
--}}
<button
    type="button"
    data-slot="hover-card-trigger"
    x-ref="trigger"
    @focusin="open = true"
    @focusout="closeWithDelay()"
    @keydown.escape.prevent="open = false"
    {{ $attributes->merge(['class' => 'cursor-pointer rounded-sm text-start underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring']) }}
>
    {{ $slot }}
</button>
resources/views/components/ui/hover-card/content.blade.php Blade
@props([
    'lazy' => false,
    // Which side of the trigger the card opens on: bottom (default) or top.
    'side' => 'bottom',
    // Drop the inset so edge-to-edge content (a preview stage, an image) can
    // fill the card; the card clips to its own radius.
    'flush' => false,
])

@php
    // `lazy` (opt-in, default false): see popover/content.blade.php for the
    // identical rationale — the slot mounts/unmounts with `open` directly
    // rather than staying mounted after first open, so a long session
    // hovering through many cards does not accumulate DOM. The
    // `@if`/`@else`/`@endif` markup below is deliberately un-indented: an
    // indented directive line leaks stray spaces into the plain (non-lazy)
    // output and breaks the byte-identical default render — see
    // popover/content.blade.php for the full explanation.
    $placement = $side === 'top' ? 'bottom-full mb-2 origin-bottom' : 'mt-2 origin-top';
    $inset = $flush ? 'overflow-hidden' : 'p-4';
@endphp

<div
    data-slot="hover-card-content"
    data-side="{{ $side === 'top' ? 'top' : 'bottom' }}"
    x-show="open"
    x-cloak
    @mouseenter="cancelClose()"
    @mouseleave="closeWithDelay()"
    x-transition:enter="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
    x-transition:enter-start="opacity-0 motion-safe:scale-95 motion-safe:-translate-y-1"
    x-transition:enter-end="opacity-100 motion-safe:scale-100 motion-safe:translate-y-0"
    x-transition:leave="motion-safe:transition motion-safe:duration-100 motion-safe:ease-in"
    x-transition:leave-start="opacity-100 motion-safe:scale-100 motion-safe:translate-y-0"
    x-transition:leave-end="opacity-0 motion-safe:scale-95 motion-safe:-translate-y-1"
    {{ $attributes->merge(['class' => 'absolute z-popover w-64 max-w-none rounded-md border border-border bg-popover text-sm text-popover-foreground shadow-md motion-reduce:transition-none '.$placement.' '.$inset]) }}
>
@if ($lazy)
        <template x-if="open">
            {{ $slot }}
        </template>
@else
    {{ $slot }}
@endif
</div>
resources/js/ui/hover-card.js JS
/**
 * Hover Card behavior. Opens on hover and keyboard focus, with an open delay
 * (so a brief pass-through does not trigger it) and a close delay (so moving
 * the pointer from the trigger into the card keeps it open). Self-registering
 * Alpine data component.
 *
 * v1.2.0 adds an opt-in `group` (see hover-card.blade.php), identical in
 * spirit to popover's: opening dispatches a `ui-hover-card:opened` window
 * event carrying its group name and a per-instance id, and every other hover
 * card sharing that group name closes immediately on hearing it. Ungrouped
 * hover cards (the default) never attach a listener or dispatch the event.
 * The dispatch lives behind an Alpine `$watch('open', ...)` rather than being
 * inlined into `openWithDelay()`, because `open` can also flip true directly
 * from the trigger's `@focusin="open = true"` (keyboard users skip the hover
 * delay entirely) — watching the property catches both paths from one place
 * instead of silently missing the keyboard one. Closing this way skips the
 * normal delay (there's no pointer to debounce against) and, if focus was
 * inside the card being closed, returns it to this card's own trigger rather
 * than leaving it stranded on a removed node.
 *
 * v1.5.0 adds a keyboard "peek": with a `name`, the card listens for the
 * window events `hover-card:open`, `hover-card:close` and `hover-card:toggle`
 * carrying { name } (a list row's Space handler dispatches them). The card
 * opens at once without moving focus, and Escape (a window listener that
 * lives only while a peek is open) closes it. `openDelay` / `closeDelay`
 * tune the hover delays.
 */
document.addEventListener('alpine:init', () => {
    let hoverCardInstanceSeq = 0;

    window.Alpine.data('uiHoverCard', (group = null, options = {}) => ({
        open: false,
        name: options.name ?? null,
        openDelay: Number.isFinite(options.openDelay) ? options.openDelay : 600,
        closeDelay: Number.isFinite(options.closeDelay) ? options.closeDelay : 250,
        _onPeek: null,
        _onPeekEscape: null,
        openTimer: null,
        closeTimer: null,
        group,
        _instanceId: `hover-card-${++hoverCardInstanceSeq}`,
        _onGroupOpen: null,

        init() {
            this.initPeek();
            if (!this.group) {
                return;
            }
            this.$watch('open', (isOpen) => {
                if (!isOpen) {
                    return;
                }
                window.dispatchEvent(
                    new CustomEvent('ui-hover-card:opened', {
                        detail: { group: this.group, id: this._instanceId },
                    })
                );
            });
            this._onGroupOpen = (event) => {
                const detail = event.detail || {};
                if (detail.group !== this.group || detail.id === this._instanceId || !this.open) {
                    return;
                }
                clearTimeout(this.openTimer);
                clearTimeout(this.closeTimer);
                const panel = this.$root.querySelector('[data-slot="hover-card-content"]');
                const stoleFocus = !!panel && panel.contains(document.activeElement);
                this.open = false;
                if (stoleFocus) {
                    this.$refs.trigger?.focus();
                }
            };
            window.addEventListener('ui-hover-card:opened', this._onGroupOpen);
        },

        initPeek() {
            if (!this.name) {
                return;
            }
            this._onPeek = (event) => {
                const detail = event.detail || {};
                if ((detail.name ?? detail.id) !== this.name) {
                    return;
                }
                const action = event.type.split(':')[1];
                if (action === 'open' || (action === 'toggle' && !this.open)) {
                    this.peek();
                } else {
                    this.unpeek();
                }
            };
            ['open', 'close', 'toggle'].forEach((action) => window.addEventListener(`hover-card:${action}`, this._onPeek));
            this._onPeekEscape = (event) => {
                if (event.key !== 'Escape' || !this.open || event.defaultPrevented) {
                    return;
                }
                event.preventDefault();
                this.unpeek();
            };
            this.$watch('open', (isOpen) => {
                if (!isOpen) {
                    window.removeEventListener('keydown', this._onPeekEscape, true);
                }
            });
        },

        /** Open at once, without moving focus (a keyboard peek). */
        peek() {
            clearTimeout(this.openTimer);
            clearTimeout(this.closeTimer);
            this.open = true;
            window.removeEventListener('keydown', this._onPeekEscape, true);
            window.addEventListener('keydown', this._onPeekEscape, true);
        },

        unpeek() {
            clearTimeout(this.openTimer);
            clearTimeout(this.closeTimer);
            this.open = false;
        },

        openWithDelay() {
            clearTimeout(this.closeTimer);
            this.openTimer = setTimeout(() => (this.open = true), this.openDelay);
        },

        closeWithDelay() {
            clearTimeout(this.openTimer);
            this.closeTimer = setTimeout(() => (this.open = false), this.closeDelay);
        },

        cancelClose() {
            clearTimeout(this.closeTimer);
        },

        destroy() {
            clearTimeout(this.openTimer);
            clearTimeout(this.closeTimer);
            if (this._onPeek) {
                ['open', 'close', 'toggle'].forEach((action) => window.removeEventListener(`hover-card:${action}`, this._onPeek));
                this._onPeek = null;
            }
            if (this._onPeekEscape) {
                window.removeEventListener('keydown', this._onPeekEscape, true);
                this._onPeekEscape = null;
            }
            if (this._onGroupOpen) {
                window.removeEventListener('ui-hover-card:opened', this._onGroupOpen);
                this._onGroupOpen = null;
            }
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.5.2
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