Capítulo 8 de 51

Chapter 8: Customization

Core Idea

Base UI's change events (onOpenChange, onValueChange, onPressedChange, ...) all receive a second eventDetails argument carrying the change reason, the native event, and cancel()/allowPropagation() methods — this is the mechanism for intercepting, canceling, or letting propagate any state change without fully taking over control of the component.

Key Concepts

  • Event signature: onOpenChange: (open, eventDetails) => void (and equivalently for onValueChange, onPressedChange, etc.).
  • eventDetails shape: { reason: string, event: Event, cancel: () => void, allowPropagation: () => void, isCanceled: boolean, isPropagationAllowed: boolean }.
    • reason explains why the change happened (IDE autocomplete after reason === ' shows the possible values for that component) — branch logic on it instead of guessing from the DOM event.
    • cancel() stops the component's internal state from changing — lets you veto a change while keeping the component uncontrolled, as an alternative to full external control.
    • allowPropagation() overrides the default propagation-stopping behavior (e.g. most components stop Esc from closing parent popups too) so the DOM event bubbles further.
  • event.preventBaseUIHandler(): escape hatch to stop Base UI's own default handling of a React event (e.g. onPaste) when no dedicated customization prop exists yet. Has no effect on components that listen to native events instead of React synthetic events.
  • Uncontrolled vs. controlled: components default to uncontrolled (internal state). Pass the state prop (open, value, ...) plus its setter to the matching change handler (onOpenChange, onValueChange, ...) to control externally — e.g. opening a Dialog on a timeout with no trigger at all.

Code Examples

/* Cancel a state change based on reason */
<Tooltip.Root onOpenChange={(open, eventDetails) => {
  if (eventDetails.reason === 'trigger-press') {
    eventDetails.cancel();
  }
}}>...</Tooltip.Root>
/* Allow Esc to propagate to a parent popup instead of being swallowed */
<Tooltip.Root onOpenChange={(open, eventDetails) => {
  if (eventDetails.reason === 'escape-key') {
    eventDetails.allowPropagation();
  }
}}>...</Tooltip.Root>
/* Fully controlled Dialog, opened without a Trigger */
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
  const t = setTimeout(() => setOpen(true), 1000);
  return () => clearTimeout(t);
}, []);
<Dialog.Root open={open} onOpenChange={setOpen}>No trigger needed.</Dialog.Root>
  • What it demonstrates: cancel()/allowPropagation() let you intercept specific interaction reasons surgically, instead of reimplementing the whole open/close state machine externally just to block one case (e.g. "don't close on trigger press, but do close on Esc").

Key Takeaways

  1. Prefer eventDetails.cancel() over converting a component to fully controlled state when you only need to veto one specific interaction reason — it's less code and keeps the component's own state machine authoritative.
  2. Check reason before assuming which interaction caused a change — the same onOpenChange fires for trigger clicks, outside clicks, Esc, and programmatic changes alike.
  3. preventBaseUIHandler() only works on React synthetic events; if a component uses native DOM listeners internally, this method won't intercept them — check the component's own chapter for its actual event-handling mechanism.

Connects To

  • ch021 (Dialog), ch047 (Tooltip), ch027 (Menu): components whose docs reference these exact eventDetails patterns for common customizations like escape-key handling and outside-press behavior.
  • ch009 (Forms): form-specific validation events build on this same eventDetails convention.