Composable Overlays
@plaud/design's children-as-trigger overlays — Tooltip / DropdownMenu / Popover /
HoverCard — can be freely nested so that multiple overlays bind their event handlers and
ref onto the same real DOM element. Business code only writes the natural nesting; it
does not need to wire asChild by hand.
// Tooltip on hover + DropdownMenu on click, same button. Either nesting order is equivalent.
<Tooltip content="More actions">
<DropdownMenu items={items}>
<Button>⋮</Button>
</DropdownMenu>
</Tooltip>
- Mechanism: driven by
src/utils/slottable-trigger.ts'sextractForwardableTriggerProps. Each high-level overlay isforwardRef, forwards the composable injected props (events + aria relations + conditionalid) to its innerTrigger/Anchor, and lets RadixSlotcompose events and ref onto the real DOM. - What composes: only event handlers and ref. Same-name
aria-*/data-statecan hold a single value on one DOM node — that is a Web platform limit, not something this layer removes. - Full design:
packages/design/docs/overlay-composable-triggers.md.
The live cases below double as a manual test matrix. Each lists an Expected result; the
on-screen counters prove the business onClick is not swallowed.
:::caution Focus interaction (Tooltip / HoverCard + DropdownMenu)
A focus-opening overlay (Tooltip / HoverCard) on the same trigger as a DropdownMenu hits a Radix focus interaction: a modal DropdownMenu restores focus to the trigger on close, which re-fires the trigger's onFocus and makes the Tooltip / HoverCard flash back open. The composition layer only merges events and ref — it does not harmonize the two overlays' open state. Recommended workaround for this pattern: set the DropdownMenu to modal={false} so closing via an outside click does not refocus the trigger. The cases below already apply it.
:::
Case 1 — Tooltip (outer) + DropdownMenu (inner)
The same button shows a Tooltip on hover and opens the menu on click. The button keeps its own onClick.
Expected: hover → Tooltip appears; click → menu opens and the Tooltip auto-dismisses; the click counter increments on every open click.
const Demo = () => { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '48px 0' }}> <Tooltip content="More actions"> <DropdownMenu modal={false} items={[{ label: 'Rename' }, { label: 'Duplicate' }, { type: 'separator' }, { label: 'Delete', variant: 'danger' }]} > <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Hover + Click me </Button> </DropdownMenu> </Tooltip> <span className="composable-overlays__counter">Button onClick fired: {count}</span> </div> ) } render(<Demo />)
Case 2 — DropdownMenu (outer) + Tooltip (inner)
Reverse nesting order; behavior must be equivalent to Case 1.
Expected: identical to Case 1 — hover shows the Tooltip, click opens the menu, business
onClickstill fires.
const Demo = () => { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '48px 0' }}> <DropdownMenu modal={false} items={[{ label: 'Share' }, { label: 'Move to…' }, { label: 'Archive' }]}> <Tooltip content="More actions"> <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Hover + Click me </Button> </Tooltip> </DropdownMenu> <span className="composable-overlays__counter">Button onClick fired: {count}</span> </div> ) } render(<Demo />)
Case 3 — Tooltip (outer) + Popover (inner)
Hover surfaces a hint; click opens the popover card.
Expected: hover → Tooltip "Edit profile"; click → Popover opens; the Tooltip does not block the click.
const Demo = () => { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '48px 0' }}> <Tooltip content="Edit profile"> <Popover title="Profile" content="Click outside or press Esc to close this popover." side="bottom" > <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Hover + Click me </Button> </Popover> </Tooltip> <span className="composable-overlays__counter">Button onClick fired: {count}</span> </div> ) } render(<Demo />)
Case 4 — HoverCard (outer) + DropdownMenu (inner)
A hover-triggered card and a click-triggered menu share the same trigger.
Expected: hover → HoverCard appears after the open delay; click → menu opens. Both interactions stay independent.
const Demo = () => { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '64px 0' }}> <HoverCard title="@plaud" content="HoverCard shows richer context on hover, while the menu handles actions on click." side="bottom" align="start" > <DropdownMenu modal={false} items={[{ label: 'View profile' }, { label: 'Copy link' }, { label: 'Report' }]}> <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Hover + Click me </Button> </DropdownMenu> </HoverCard> <span className="composable-overlays__counter">Button onClick fired: {count}</span> </div> ) } render(<Demo />)
Case 5 — content == null early-return (Slot merge)
When the overlay has no content it renders nothing extra, but must still merge the business onClick / ref via Radix Slot instead of swallowing them with a bare cloneElement.
Expected: no Tooltip appears (content is empty), but every click still increments the counter, and the focused ref reads back the real
<button>tag on mount.
const Demo = () => { const [count, setCount] = useState(0) const [tag, setTag] = useState('—') const ref = useRef(null) useEffect(() => { setTag(ref.current ? ref.current.tagName.toLowerCase() : 'null') }, []) return ( <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '40px 0' }}> <Tooltip ref={ref}> <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Click me (no tooltip) </Button> </Tooltip> <span className="composable-overlays__counter"> onClick fired: {count} · ref resolved to: <{tag}> </span> </div> ) } render(<Demo />)
Case 6 — Triple nesting
Three overlays stacked on one trigger, mixing hover and click sources.
Expected: hover → Tooltip; click → DropdownMenu opens; the inner HoverCard's hover content also resolves. No interaction swallows another.
const Demo = () => { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '64px 0' }}> <Tooltip content="Tooltip layer"> <DropdownMenu modal={false} items={[{ label: 'Action A' }, { label: 'Action B' }]}> <HoverCard title="HoverCard layer" content="The innermost hover content." side="bottom"> <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Hover + Click me </Button> </HoverCard> </DropdownMenu> </Tooltip> <span className="composable-overlays__counter">Button onClick fired: {count}</span> </div> ) } render(<Demo />)
Case 7 — Standalone regression (no composition)
Each overlay used on its own must behave exactly as before — this guards the "business props pass-through" regression.
Expected: Popover opens on click with its own content; the standalone button's
onClickfires normally; nothing changed versus the single-component pages.
const Demo = () => { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', gap: 24, alignItems: 'center', padding: '48px 0' }}> <Popover title="Standalone" content="Plain Popover, no nesting."> <Button variant="secondary" onClick={() => setCount((c) => c + 1)}> Standalone Popover </Button> </Popover> <span className="composable-overlays__counter">Button onClick fired: {count}</span> </div> ) } render(<Demo />)