Detached Triggers
Separate trigger buttons from their associated components for flexible UI layouts.
Cubitt components built on Base UI support detached triggers, allowing you to physically separate a component's trigger (like a button that opens a dialog) from the component's root element in your JSX structure while keeping them functionally connected.
Why Detached Triggers?
Traditional Approach
Normally, you nest the trigger inside the component's root element:
<Dialog>
<DialogTrigger>Open Dialog</DialogTrigger>
<DialogContent>...</DialogContent>
</Dialog>The <DialogTrigger> must be a child of <Dialog>. This works fine but becomes limiting when:
- Your trigger needs to be in a different part of the UI hierarchy
- You want several buttons in different places to open the same dialog
- The trigger and content are managed by different components
Detached Approach
With detached triggers, you use a handle to connect components that are not parent-child in the DOM:
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
// 1. Create a handle
const myDialog = DialogPrimitive.createHandle();
// 2. Place trigger ANYWHERE in your app
<DialogTrigger handle={myDialog}>Open Dialog</DialogTrigger>
// 3. Place root ANYWHERE ELSE
<Dialog handle={myDialog}>
<DialogContent>...</DialogContent>
</Dialog>The handle acts as a "wireless connection" between the trigger and the root.
How It Works
Create a Handle
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
const myDialog = DialogPrimitive.createHandle();The createHandle function returns a special reference object that:
- Manages the open/close state internally
- Coordinates between triggers and the root component
- Maintains accessibility relationships (ARIA attributes)
- Handles focus management
Link Components
Both the trigger and root accept a handle prop:
<DialogTrigger handle={myDialog} />
<Dialog handle={myDialog} />When you click the trigger, it signals through the handle to open the dialog, even if they're in completely different parts of the DOM tree.
Supported Components
The following Cubitt components support detached triggers:
Multiple Triggers
A single dialog can have multiple trigger elements. You can achieve this in two ways:
Within Root
Place multiple <DialogTrigger> components inside the <Dialog> - no handle needed:
<Dialog>
<DialogTrigger render={<Button />}>Open from here</DialogTrigger>
<DialogTrigger render={<Button variant="outline" />}>Or from here</DialogTrigger>
<DialogTrigger render={<Button variant="ghost" />}>Or here too</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Example Dialog</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>Detached
Use a handle to connect multiple triggers placed anywhere in your app:
const confirmDialog = DialogPrimitive.createHandle();
// Trigger in navbar
<DialogTrigger handle={confirmDialog} render={<Button />}>
Delete from Header
</DialogTrigger>
// Trigger in sidebar
<DialogTrigger handle={confirmDialog} render={<Button />}>
Delete from Sidebar
</DialogTrigger>
// Trigger in context menu
<DialogTrigger handle={confirmDialog} render={<Button />}>
Delete from Context Menu
</DialogTrigger>
// Single dialog responds to all triggers
<Dialog handle={confirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle>
</DialogHeader>
<DialogBody>Are you sure?</DialogBody>
</DialogContent>
</Dialog>Programmatic Control
Handles also provide methods for programmatic control:
const myDialog = DialogPrimitive.createHandle();
// Open programmatically
<Button onClick={() => myDialog.open()}>
Open via Method
</Button>
// Close programmatically
<Button onClick={() => myDialog.close()}>
Close via Method
</Button>
<Dialog handle={myDialog}>
<DialogContent>...</DialogContent>
</Dialog>Triggers with Payload
The dialog can render different content depending on which trigger opened it. This is achieved by passing a payload to the trigger and using the function-as-a-child pattern.
Create a Typed Handle
Define your payload type when creating the handle:
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
// Define the payload type
const userDialog = DialogPrimitive.createHandle<{
action: "view" | "edit" | "delete";
userId: number;
userName: string;
}>();Add Payload to Each Trigger
<DialogTrigger
handle={userDialog}
payload={{ action: "view", userId: 1, userName: "John" }}
render={<Button variant="ghost" />}
>
View Profile
</DialogTrigger>
<DialogTrigger
handle={userDialog}
payload={{ action: "edit", userId: 1, userName: "John" }}
render={<Button variant="outline" />}
>
Edit Profile
</DialogTrigger>
<DialogTrigger
handle={userDialog}
payload={{ action: "delete", userId: 1, userName: "John" }}
render={<Button variant="destructive" />}
>
Delete User
</DialogTrigger>Render Content Based on Payload
Use the function-as-a-child pattern to access the payload:
<Dialog handle={userDialog}>
{({ payload }) => (
<DialogContent>
<DialogHeader>
<DialogTitle>
{payload?.action === "view" && `Viewing ${payload.userName}`}
{payload?.action === "edit" && `Editing ${payload.userName}`}
{payload?.action === "delete" && `Delete ${payload.userName}?`}
</DialogTitle>
</DialogHeader>
<DialogBody>
{payload?.action === "delete" ? (
<p>This action cannot be undone.</p>
) : (
<p>User ID: {payload?.userId}</p>
)}
</DialogBody>
<DialogFooter>
<DialogClose>Cancel</DialogClose>
{payload?.action === "delete" ? (
<DialogAction variant="destructive">Delete</DialogAction>
) : (
<DialogAction>Confirm</DialogAction>
)}
</DialogFooter>
</DialogContent>
)}
</Dialog>Combining with URL State
Detached triggers work seamlessly with URL state management:
const myDialog = DialogPrimitive.createHandle();
// Trigger anywhere
<DialogTrigger handle={myDialog} render={<Button />}>
Open Settings
</DialogTrigger>
// Dialog with URL state
<Dialog handle={myDialog} paramName="settings">
<DialogContent>...</DialogContent>
</Dialog>The dialog will sync with ?settings=true in the URL while still responding to the detached trigger.