Cloud|

@k2b/ui

HoverPreview

A card beside a list row or item that a resting mouse opens without opening the item.

createHoverPreview@k2b/uiHoverPreview@k2b/ui

Rest the mouse on a row, or press Space on a focused row. One fixed-size card opens beside the list, swaps between rows, and never covers the list; without room beside it, it does not open.

Question about invoice 2026-0418

Good morning, the invoice from 12 September lists the hall rent twice. Could you send a corrected copy? We will transfer the amount right away.

TSX

Copy
const preview = createHoverPreview<string>({
openDelay: 200,
placement: { beside: () => list, within: () => frame },
disabled: (id) => id === openedId(),
});
<div ref={list} role="list">
<For each={messages}>
{(item) => (
<div ref={preview.anchor(item.id)} role="listitem">
<a href={item.href} aria-controls={preview.id} aria-expanded={preview.active() === item.id}>
{item.subject}
</a>
</div>
)}
</For>
</div>
<HoverPreview preview={preview} label="Quick look" size="fixed">
{(id) => <MessageCard id={id} />}
</HoverPreview>

HoverPreview is a non-modal card that opens beside the item a resting mouse points at. It shows a short look at that item without opening it. One card serves a whole group of anchors, such as the rows of a list, and swaps its content as the pointer moves from row to row.

Use HoverPreview

Use it when people scan a list and want to check an item before they open it: the start of a message, the facts of a record, or details of a navigation entry. The card supplements the item; it never replaces opening it. Hovering changes no state, so a preview must not mark anything as read or seen.

Keep the card to facts and a short excerpt. Use a Tooltip for a one-line hint and a dialog or detail panel for work. Pointer and keyboard users must reach everything in the card some other way, usually by opening the item.

AppWorkspace.SidebarItem uses the same card for its preview.

Import

tsx
import {
  createHoverPreview,
  HoverPreview,
  type HoverPreviewController,
  type HoverPreviewOptions,
  type HoverPreviewPlacement,
} from "@k2b/ui";

Contracts

createHoverPreview<T>(options) creates the behavior for one group. Call it in a component; the card lives as long as that component.

Option Type Default Purpose
openDelay number 250 Milliseconds a resting mouse waits on an anchor before the card opens.
keyboard "space" | "focus" "space" Space on the focused anchor toggles the card, or focus opens it after the delay.
placement HoverPreviewPlacement beside the anchor Where the card opens; see below.
disabled (value: T) => boolean none Anchors with a disabled value open no card. An open card closes once its value becomes disabled.
onOpenChange (open: boolean) => void none Runs on every open and close, including light dismissal.

The controller has these members:

Member Purpose
anchor(value, trigger?) Returns a ref that registers an element as the anchor for value. trigger is the control focus returns to; it defaults to the anchor.
active() The value whose card is open, or undefined. Use it for a row highlight or aria-expanded.
toggle(value) Opens the card at once from a control and moves focus into it, or closes it when it is already pinned open.
close() Closes the card. It opens again for the same anchor only after the pointer left it.
id The card element id, for aria-controls.

<HoverPreview preview={…} label="…"> renders the card. A function child receives the open anchor's value and renders when the card opens or swaps. Plain children stay mounted while the card is closed. size="fixed" keeps one 22rem × 20rem box for every anchor, so content never resizes or moves the card; the default sizes it to its content, 22rem wide and at most 32rem tall. The card adds no padding and clips its content; the content owns padding, line clamps, and a fade at the bottom.

Placement

By default the card opens to the right of its anchor, centered on it. With { align: "end" } its bottom edge aligns with the anchor instead. The card flips to the left or clamps when the right side does not fit.

With { beside: () => listElement, within: () => frameElement } the card opens 8px right of that region, top-aligned with the anchor, and is clamped into within (default: the viewport) with an 8px margin. A card near the bottom moves up until it fits. When there is no room for the card and both gaps right of the region, it does not open at all, so it never covers the list.

Timing

  • A resting mouse opens the card after openDelay. The delay starts again while the mouse moves over the anchor, and a press on the anchor cancels it.
  • The card does not open while a menu, date picker, or other light-dismiss popover is open, so it never closes one that someone opened.
  • With the card open, moving to another anchor swaps the content after 90 ms.
  • The card stays open while the pointer moves into it. It closes 180 ms after the pointer leaves the anchor and the card.
  • Space and toggle() pin the card to their anchor: it stays open after the pointer leaves. Hovering another anchor until the card swaps drops the pin, so that card closes like any hovered one.
  • Escape closes the card, also before it opens during the delay. It reopens for that anchor only after the pointer left it.
  • A card placed beside a region closes when that region or the page scrolls. A card beside its anchor follows it.

Loading the content

When the card needs a request, start it once the mouse has rested on an anchor for part of openDelay: restart that rest on every mouse move and cancel it when the pointer leaves or the list scrolls. Every row of a fast sweep fires pointerenter, and so does every row that passes under a still pointer while the list scrolls, so a request per pointerenter costs one request per passing row. Abort a request when the pointer leaves its anchor before the card opens. While a newer version of the same item loads, keep showing the previous answer instead of placeholders.

Accessibility

Touch and pen input never open the card; it is a mouse affordance. Give touch users the same information when they open the item.

The card is a non-modal dialog named by label. With the default keyboard="space", Space on a focused anchor toggles the card and focus stays on the anchor; Enter keeps its meaning, such as following a link. Holding Space toggles once, and Space with a modifier, such as Shift+Space to scroll up, leaves the card alone. Space on a button, checkbox, radio, switch, option, menu item, tab, tree item, or field inside the anchor keeps that control's own meaning. While the card is open, focusing another anchor of the group moves the card there, and a pin moves with it. In this mode focus alone never keeps a card open that the mouse opened. With keyboard="focus", focus on an anchor opens the card after the delay and keeps it open while focus stays there.

Put aria-controls={preview.id} and aria-expanded={preview.active() === value} on the focusable element of each anchor, usually its link. Screen readers then announce the row as collapsed or expanded when Space toggles the card. Focus stays on the row, so they do not read the card itself: it must only repeat what opening the item shows.

toggle(value) moves focus into the card so interactive content is reachable. Escape with focus inside the card returns it to the trigger and shows the focus ring when the keyboard opened it. Escape from a card that only the pointer opened leaves the key to other handlers, such as an open modeless dialog.

Runtime

The card renders on the server as a closed native popover (popover="auto"), so outside clicks and other popovers dismiss it; it never dismisses another light-dismiss popover itself. Hydration registers the anchors and positions the card in the top layer; it never changes the layout of the anchors or anything around them. The card uses the elevated surface, a strong border, and inner depth only: no outer shadow and no divider lines. It fades in and out unless reduced motion is requested.

Example

tsx
const preview = createHoverPreview<string>({
  openDelay: 200,
  placement: { beside: () => list, within: () => frame },
  disabled: (id) => id === openConversationId(),
});

<div ref={list} role="list">
  <For each={conversations()}>
    {(item) => (
      <div ref={preview.anchor(item.id)} role="listitem" data-peek={preview.active() === item.id}>
        <a href={item.href} aria-controls={preview.id} aria-expanded={preview.active() === item.id}>
          {item.subject}
        </a>
      </div>
    )}
  </For>
</div>
<HoverPreview preview={preview} label="Quick look" size="fixed">
  {(id) => <ConversationCard id={id} />}
</HoverPreview>

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.