Skip to content
Velocity X Docs

Standard Overlays

Controlled and uncontrolled overlays

Every overlay listed below accepts the same three props — open, defaultOpen, onOpenChange — so you can drive it from your own application state, or hand it off and let it manage itself. The pattern is identical across all of them; learn it once here instead of re-deriving it from each component's source.

The one rule

Pass open and you own the state. Omit it and the component owns the state. There's no partial mode — a component checks whether open is undefined and picks one or the other for its whole lifetime. Don't pass open on some renders and omit it on others; pick a mode per usage site and stay in it.

Uncontrolled (the default)

Omit open entirely and the overlay manages its own open/closed state internally — clicking its trigger, pressing Escape, clicking the backdrop and clicking its own close button all just work with zero wiring. Use defaultOpen to start it open instead of closed.

tsx
// Uncontrolled — the component manages its own state
<ModalDialog trigger={<button>Edit profile</button>}>
  <p>Form goes here.</p>
</ModalDialog>

// Start it open on mount, then let it manage itself from there
<SlideOverDrawer defaultOpen trigger={<button>Filters</button>}>
  <FilterForm />
</SlideOverDrawer>

Controlled

Pass open and the overlay never changes its own state again — you flip it, typically from a useState in the parent. This is what lets a drawer be triggered from a row in a table, a keyboard shortcut, a deep link, or any other place that isn't the overlay's own trigger element.

tsx
const [open, setOpen] = useState(false);

<SlideOverDrawer open={open} onOpenChange={setOpen}>
  <EditForm onSaved={() => setOpen(false)} />
</SlideOverDrawer>

// Open it from anywhere else in the app
<button onClick={() => setOpen(true)}>Edit from the table row</button>

The mistake people actually make

onOpenChange fires on every internally-initiated change — trigger click, Escape, clicking the backdrop, clicking the overlay's own close button. In controlled mode the overlay doesn't close itself for any of those; it calls onOpenChange(false) and waits for the parent to actually flip open. Pass open without handling onOpenChange and the overlay will visibly refuse to close — Escape, backdrop, close button, all of it stop working, because nothing is listening.

tsx
// Wrong — open is controlled, but nothing updates it.
// Escape / backdrop / close button all do nothing.
<ModalDialog open={true}>...</ModalDialog>

// Right — onOpenChange is wired back into the state that drives open
<ModalDialog open={open} onOpenChange={setOpen}>...</ModalDialog>

ContextMenu's extra prop: position

ContextMenu is the one component in this list with a prop beyond the shared three. A right-click normally resolves its own coordinates from the click event — but a controlled open with no coordinates is only half an API, because the menu still has to render somewhere. Pass position when you're opening the menu programmatically (a keyboard shortcut, an action button) rather than from a native right-click, so the menu knows where to land.

tsx
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });

function openFromButton(e: React.MouseEvent) {
  const rect = e.currentTarget.getBoundingClientRect();
  setPosition({ x: rect.left, y: rect.bottom });
  setOpen(true);
}

<ContextMenu open={open} onOpenChange={setOpen} position={position}>
  <MenuItem onSelect={...}>Rename</MenuItem>
  <MenuItem onSelect={...}>Delete</MenuItem>
</ContextMenu>

Omit position and a controlled ContextMenu still resolves its own coordinates from whatever triggered it — the prop only matters once you're opening it from somewhere that isn't a right-click.

Which components have it

18 of the 25 published components in Standard Overlays & Menus carry this API. The rest in that category — Anchored Callout Badge, Coach-Mark Tour Step, Inline Notification Banner, Stacked Sheet Layers, Toast Stack, Tooltip and Undo Snackbar — don't take open/defaultOpen/onOpenChange, mostly because they're driven by a timer, a queue, or a hover delay rather than a single open/closed flag. Check the component's own reference page before assuming it's controllable.