Skip to main content

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's extractForwardableTriggerProps. Each high-level overlay is forwardRef, forwards the composable injected props (events + aria relations + conditional id) to its inner Trigger / Anchor, and lets Radix Slot compose events and ref onto the real DOM.
  • What composes: only event handlers and ref. Same-name aria-* / data-state can 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.

Result
Loading...
Live Editor
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 onClick still fires.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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: &lt;{tag}&gt;
      </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.

Result
Loading...
Live Editor
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 onClick fires normally; nothing changed versus the single-component pages.

Result
Loading...
Live Editor
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 />)