Drover

A responsive overlay that renders as a popover on desktop and a bottom drawer on mobile, with a single compound API.

Drover is a responsive floating container. Above the mobile breakpoint it mounts as a Radix Popover anchored to the trigger; below it, the same tree re-mounts as a Vaul bottom Drawer with a swipe-dismissable sheet. You write one compound markup and the component picks the right primitive at runtime based on viewport width.

Reach for Drover when the content is the same on every screen but the ergonomics should differ: a workspace switcher, a filter panel, an actions menu that’s fine as a popover on a laptop but deserves the full-width sheet treatment on a phone. If the content is always a modal-scale form, use Dialog. If you want a bottom sheet on every device, use Drawer directly. If the content is a persistent side panel, reach for SlidePanel.

Import

import { Drover } from "@unkey/ui";

Drover is a compound component exported as a namespace. You assemble the overlay from Drover.Root, Drover.Trigger, Drover.Content, and optionally Drover.Close and Drover.Nested.

Basic

Wrap a control with Drover.Trigger asChild so the Button stays the real focusable element, then pair it with Drover.Content. Resize the viewport below 768px to see the popover turn into a bottom drawer.

Controlled

Pass open and onOpenChange to Drover.Root when the parent needs to drive visibility (for example, opening the drover in response to an event outside the trigger tree). When uncontrolled, use defaultOpen to start open on first render.

Nested

Drover.Nested stacks a second drover inside the first. On mobile, Vaul animates the parent sheet back so the nested sheet owns the viewport; closing the nested drover programmatically closes its parent as well, so the user never ends up with a dangling root.

Accessibility

Each underlying primitive carries the right semantics on its own branch. On desktop, Drover.Content is a Radix Popover: it renders into a portal, traps nothing (so tab order continues through the document), closes on Escape, and restores focus to the trigger on dismiss. On mobile, it is a Vaul drawer: focus moves into the sheet on open, Escape closes it, and swiping down or clicking the scrim dismisses.

Two things to keep in mind when composing. First, Drover.Trigger renders a <button> by default. Use asChild to merge its behavior into an existing focusable control so you don’t nest two buttons. Second, the switch between popover and drawer happens at 768px viewport width and is driven by window.matchMedia. On the server and during first paint the component assumes desktop to avoid hydration mismatches, so a phone user will see the popover shape for a frame before it swaps to the drawer. Keep the first render’s layout forgiving (no absolute widths pinned to the trigger) so the transition isn’t jarring.

Props

Drover.Root

The state container. Mounts either a Popover or a Drawer root depending on viewport width.

open boolean Optional

Controlled open state. Pair with onOpenChange when the parent needs to drive visibility.

defaultOpen boolean Optional Default false

Uncontrolled initial open state. Ignored if open is provided.

onOpenChange (open: boolean) => void Optional

Fires whenever the open state changes, whether by trigger, close, escape, swipe dismiss, or programmatic control.

children ReactNode Optional

A Drover.Trigger and a Drover.Content, optionally with a Drover.Close or a Drover.Nested inside the content.

Drover.Trigger

The control that opens the drover. Forwards to Popover’s trigger on desktop and Vaul’s trigger on mobile.

asChild boolean Optional Default false

Merge the trigger behavior into a single child instead of rendering a wrapping <button>. Use this to keep a Button as the focusable element.

children ReactNode Optional

The trigger content. With asChild, must be a single element that accepts a ref and standard pointer/focus handlers.

...rest ButtonHTMLAttributes<HTMLButtonElement> Optional

Standard button attributes pass through to the underlying primitive.

Drover.Content

The floating surface. On desktop, applies popover chrome (min-w-60, bg-gray-1, shadow-2xl, border-gray-6, rounded-lg, 0.5rem padding) and anchors with align="start". On mobile, renders Vaul’s sheet chrome (rounded top corners, drop shadow, max 82vh height) directly.

className string Optional

Additional Tailwind classes. Merged with the desktop popover chrome only; the mobile drawer chrome is applied by the underlying Drawer.Content and not overridden here.

onKeyDown (e: KeyboardEvent) => void Optional

Key handler forwarded to the underlying popover or drawer content.

onAnimationEnd (e: AnimationEvent) => void Optional

Animation-end handler forwarded to the underlying primitive.

children ReactNode Optional

The content body. Keep layout fluid so the switch between popover and drawer at 768px doesn’t cause a jarring reflow.

Drover.Close

A button that closes the drover by calling onOpenChange(false) on the root. Uses a Radix Slot when asChild is set, letting an existing button provide its own styling and focus ring.

asChild boolean Optional Default false

Merge the close behavior into a single child instead of rendering a wrapping <button>.

children ReactNode Optional

The close trigger content. Usually a Button with a dismissive label.

...rest ButtonHTMLAttributes<HTMLButtonElement> Optional

Standard button attributes pass through.

Drover.Nested

A second drover mounted inside an open drover. On mobile, uses Vaul’s NestedRoot so the parent sheet animates back while the child takes focus. Closing the nested drover also closes the parent root, preventing orphaned overlays.

open boolean Optional

Controlled open state for the nested drover.

defaultOpen boolean Optional Default false

Uncontrolled initial open state for the nested drover.

onOpenChange (open: boolean) => void Optional

Fires on nested open-state changes.

children ReactNode Optional

A Drover.Trigger and a Drover.Content for the nested layer.

  • Drawer — when you always want a bottom sheet, regardless of viewport. Drover uses this primitive under the hood on mobile.
  • Dialog — for a centered, modal surface that blocks interaction with the page until confirmed or dismissed.
  • SlidePanel — for a persistent side panel that coexists with the underlying page layout instead of floating above it.