Skip to content

[BUG] - v3 Drawer cannot be controlled externally without a trigger child (state prop + useOverlayState does not open the drawer) #6347

@oferitz

Description

@oferitz

HeroUI Version

@heroui/react@3.0.1

Describe the bug

In HeroUI v3, the Drawer component cannot be opened programmatically without a trigger child element. The DrawerRoot wraps everything in React Aria's DialogTrigger, which expects its first child to be a trigger (Button) and the second child to be the overlay (Drawer.Backdrop). When no trigger child is present — which is the case for externally-controlled drawers — the Drawer.Backdrop is treated as the trigger instead of the overlay, so the drawer never opens.

This is a v2 to v3 migration regression. In v2, <Drawer isOpen={isOpen} onOpenChange={onOpenChange}> worked directly for controlled drawers without needing a trigger child. In v3, this pattern is broken.

What I tried (none work)

  1. state prop with useOverlayState<Drawer state={state}> where state = useOverlayState({ isOpen, onOpenChange }). The Drawer.Backdrop renders with display: none (has the hidden CSS class) and never becomes visible.
  2. isOpen/onOpenChange on Drawer.Backdrop — Same result: backdrop stays hidden.
  3. defaultOpen on Drawer root<Drawer defaultOpen> — Same result.
  4. useEffect calling state.open() — Opening via the state object after mount. Same result.

Root cause (from reading the source)

In drawer.tsx, DrawerRoot renders:

<DrawerTriggerPrimitive {...mergeProps(props, controlledProps)}>
  {children}
</DrawerTriggerPrimitive>

Where DrawerTriggerPrimitive is DialogTrigger from react-aria-components. DialogTrigger requires its first child to be a pressable trigger element and its second child to be the overlay (ModalOverlay). Without a trigger as the first child, the overlay is never mounted as visible.

Use case

Many apps use drawers in a controlled, externally-driven way — for example, a detail panel that opens when a URL parameter changes, or when a list item is selected. In these cases:

  • The parent component manages isOpen state
    • There is no "trigger button" — the drawer opens in response to external events (URL changes, row clicks, etc.)
      • The parent conditionally renders the drawer or passes isOpen={true} when it should be visible
        This pattern was fully supported in v2 and is documented in the v3 migration guide under "Controlled State", but does not actually work in practice without a trigger child.

Steps to Reproduce the Bug or Issue

  1. Create a controlled Drawer without a trigger child — only Drawer.Backdrop as a child of Drawer:
import { Drawer, useOverlayState } from "@heroui/react";

function MyPanel({ isOpen, onClose }) {
  const state = useOverlayState({ isOpen, onOpenChange: (open) => { if (!open) onClose() } });

  return (
    <Drawer state={state}>
      <Drawer.Backdrop>
        <Drawer.Content placement="right">
          <Drawer.Dialog>
            <Drawer.Header><Drawer.Heading>Details</Drawer.Heading></Drawer.Header>
            <Drawer.Body>Content here</Drawer.Body>
          </Drawer.Dialog>
        </Drawer.Content>
      </Drawer.Backdrop>
    </Drawer>
  );
}

// Parent component:
function App() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open</button>
      <MyPanel isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </>
  );
}
  1. Click the "Open" button to set isOpen to true
    1. The Drawer renders in the DOM but Drawer.Backdrop has display: none (the hidden CSS class is applied)
    1. The drawer never becomes visible
      Also tried: defaultOpen, isOpen/onOpenChange directly on Drawer.Backdrop, wrapping in useEffect calling state.open() — none work.

Expected behavior

When using <Drawer state={state}> with useOverlayState({ isOpen: true }) (or isOpen/onOpenChange on Drawer.Backdrop), the Drawer should open and be visible — even without a trigger child element.

The v3 migration guide shows a "Controlled State" example using state prop with useOverlayState, but this only works if a Button trigger is present as the first child of Drawer. Apps that control drawers externally (e.g., via URL params or parent state) cannot use the trigger pattern.

Suggestion: Either allow Drawer.Backdrop to work as a standalone controlled overlay (like Modal.Backdrop seems intended to), or provide a way to use Drawer without the DialogTrigger wrapper when in fully controlled mode.

Screenshots or Videos

No response

Operating System Version

macOS

Browser

Chrome

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions