ComponentsUpdated Sep 9, 2026
Action Popover
A hover toolbar for list items. Opens 250ms after the pointer enters the trigger, closes 350ms after it leaves, and never steals focus from the content beneath.
Import
Source: packages/ui/src/components/action-popover
tsx
import { ActionPopover } from '@highlight/ui'Basic
Hover the cell. Actions always live inside a Screen, which is the flex row. Content itself does not lay its children out.
Design sync recap
tsx
import { Archive, Calendar, Copy, Share } from 'lucide-react'
import { ActionPopover, Cell } from '@highlight/ui'
export default function ActionPopoverBasic() {
return (
<ActionPopover>
<ActionPopover.Trigger className="w-80">
<Cell onClick={() => {}}>
<Cell.Header>
<Cell.Icon>
<Calendar />
</Cell.Icon>
<Cell.Title>Design sync recap</Cell.Title>
</Cell.Header>
</Cell>
</ActionPopover.Trigger>
<ActionPopover.Content align="end" side="top" sideOffset={-12} alignOffset={12}>
<ActionPopover.Body>
<ActionPopover.Screen id="recap" key="actions" isActive>
<ActionPopover.Button left={<Copy />}>Copy</ActionPopover.Button>
<ActionPopover.Button left={<Share />}>Share</ActionPopover.Button>
<ActionPopover.Separator />
<ActionPopover.Button aria-label="Archive" shape="circle">
<Archive />
</ActionPopover.Button>
</ActionPopover.Screen>
</ActionPopover.Body>
</ActionPopover.Content>
</ActionPopover>
)
}Multiple screens
Body crossfades between Screens as isActive flips, and the toolbar resizes to the new content. This is how a destructive action asks for confirmation in place.
Design sync recap
tsx
import { useState } from 'react'
import { Calendar, Trash2, X } from 'lucide-react'
import { ActionPopover, Cell } from '@highlight/ui'
export default function ActionPopoverScreens() {
const [confirming, setConfirming] = useState(false)
return (
<ActionPopover>
<ActionPopover.Trigger className="w-80">
<Cell onClick={() => {}}>
<Cell.Header>
<Cell.Icon>
<Calendar />
</Cell.Icon>
<Cell.Title>Design sync recap</Cell.Title>
</Cell.Header>
</Cell>
</ActionPopover.Trigger>
<ActionPopover.Content align="end" side="top" sideOffset={-12} alignOffset={12}>
<ActionPopover.Body>
<ActionPopover.Screen id="recap" key="actions" isActive={!confirming}>
<ActionPopover.Button left={<Trash2 />} onClick={() => setConfirming(true)}>
Delete
</ActionPopover.Button>
</ActionPopover.Screen>
<ActionPopover.Screen id="recap" key="confirm" isActive={confirming}>
<span className="text-foreground/80 whitespace-nowrap px-2 text-sm leading-none">Delete this recap?</span>
<ActionPopover.Button variant="danger" left={<Trash2 />} onClick={() => setConfirming(false)}>
Delete
</ActionPopover.Button>
<ActionPopover.Button aria-label="Cancel" shape="circle" onClick={() => setConfirming(false)}>
<X />
</ActionPopover.Button>
</ActionPopover.Screen>
</ActionPopover.Body>
</ActionPopover.Content>
</ActionPopover>
)
}Parts
Compound sub-components. Every part accepts className and forwards the rest of its props to Base UI.
ActionPopover.Trigger- A div wrapper; hover on it opens the toolbar.
ActionPopover.Content- Portal + Positioner + Popup with a Toolbar root. align, side, sideOffset, alignOffset, collision props. Put a Body inside, not buttons.
ActionPopover.Body- AnimatePresence wrapper around the Screens. mode defaults to popLayout.
ActionPopover.Screen- One toolbar state, and the flex row its children sit in. id, key, isActive; only the active one renders.
ActionPopover.Button- Toolbar button built on Button. size defaults to 'small', shape to 'rounded'.
ActionPopover.Group / Separator- Optional structure within a Screen.
- open and onOpenChange make it controlled; clicks inside do not toggle it.
- Content renders a Toolbar root with no layout of its own, so buttons placed directly inside it stack vertically. Always wrap them in Body + Screen.